Compare commits

...

75 Commits

Author SHA1 Message Date
Nicola Mometto
5a43b2d35f further branch 2026-02-03 14:19:38 +00:00
Nicola Mometto
832f8ac44d wip: force run 2026-02-03 13:47:51 +00:00
Nicola Mometto
88bdc6c06c test other branch 2026-02-03 13:47:41 +00:00
Nicola Mometto
bdfc24938c wip: test pyarrow parsing 2026-01-28 09:54:26 +00:00
Romeo Van Snick
123f2dafd2 MBQL lib helpers for e2e tests (#68278)
* Rename file containing jsx to .jsx

* Add e2e helpers to build metadata provider

* Split up metadata helpers

* WIP

* Add more direct createTestQuestion helper

* Replace test

* Build cljs for tests

* Export type

* Only require entities in the state for getMetadata

* Just pass state to getMetadata

* Move EntitiesSchema to metabase/schema

* Wrap mbql e2e helpers in promise

* Split off getMetadataWithoutSettings

* Use getMetadataWithoutSettings to get metadata from state

* Build entities state manually

* Simplify wrapper

* Fix metadataProvider

* Add createTestJsQuery wrapper for cypress

* Use plain promises and fetch

* Allow passing GetMetadataOpts to createTest(Native)JsQuery directly

* Improve createTestQuestion types

* Make use of createTestJsQuery in test

* Reduce nest in createTestQuestion

* Use simplified createTestQuestion

* Split up mbql helpers

* Fix card metadata call

* Use correct card endpoint

* Pass missing options

* Convert another more complex test to createTestQuestion

* Fix types

* Use static ids for defaults to avoid import loop

* Fetch more tables by default

* Build cljs in embedding tests

* Build cljs in component tests

* Prepare backend env

* Add plugin so we can avoid building CLJS everywher

* Avoid building cljs where not needed

* Fix types

* Rename query helpers

* Split up helpers

* Compile cljs in stress tests
2026-01-26 19:54:16 +11:00
metabase-bot[bot]
16504fd3c8 [chore] Update E2E test timings with recent data (#68665)
Update E2E test timings with averaged data

Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).

🤖 Generated automatically by update-e2e-timings workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-01-26 21:50:07 +13:00
Romeo Van Snick
997b8d898e Respect abbreviate date style and be defensive around missing settings (#68661)
* Respect date_abbreviate when formatting date

* Respect abbreviate setting in month format too

* Add unit tests for abbreviated formats

* Add unit test for empty formatting settings

* Add repro for metabase#68574

* Add formatting settings unit tests
2026-01-26 15:50:42 +11:00
Alexander Polyankin
ceb518ab4b Dependency diagnostics - update the url, reset page on filter change, fix visibility checks (#68650) 2026-01-25 21:53:08 -05:00
Fraser
0ed6c4deed Link Claude Bugfix PR to Linear issue (#67710) 2026-01-26 10:44:24 +13:00
Sloan Sparger
6c09fca355 Fix NativeQueryEditor's action buttons (#68638)
fix position of buttons for native query editor
2026-01-24 20:34:41 +00:00
Aleksandr Lesnenko
049526c61c Cleanup (#68648)
cleanup
2026-01-24 09:40:18 -06:00
William
a799178420 Python transforms have dependencies (#68572)
The existing transform dependencies code only worked with query-based transforms (mbql or native).  This extends that
code to work with python transforms as well.
2026-01-24 02:34:24 -06:00
Maksym Yakubych
f7b82d5c54 fix: word wrap overflows row when reordering in dashboard table card (#68566) 2026-01-24 00:37:00 -03:00
Anderson
a51d1179d3 Disallow editing transforms when remote sync is read-only (#68380)
* Read-only: Adjust transform header menu options / Remove edit definition button / Run tab updates

* Make Settings tab read-only

* fix remote sync setup in e2e test

* Do not allow creating a new transform

* Make Python library not editable

* Hide edit definition in mbql transform view page

* Fix e2e

* Group e2e tests to improve performance
2026-01-24 01:52:27 +00:00
metamben
cbed2e45a8 Follow up on QUE-2999: move filtering to the database (#68635) 2026-01-23 23:45:05 +00:00
Timothy S. Dean
418d7a0317 Tweak data loading behavior for a test after MySQL 9.6.0 release (#68634)
Closes DEV-1289

### Description

Within the last day or two, `metabase.query-processor-test.remapping-test/self-referencing-test` began failing on MySQL Latest. I hypothesize this has to do with the [latest release](https://dev.mysql.com/doc/relnotes/mysql/9.6/en/news-9-6-0.html) fixing an unknown bug around batch inserts in InnoDB and the deprecation of a recusive [sic] fk-related flag.

The error is thrown not in the test conditions, but in the test setup -- MySQL apparently now checks foreign key constraints row-per-row even on a batch insert.

This adds a flag that the test can set which changes the test data-loading behavior to disable the FK check before insertion and re-enable it after.
2026-01-23 15:56:20 -07:00
Gabriel Moreno
3bce633480 Library empty state: display content in the page body (#68628)
* feat: create library empty state

* refactor: use new designs

* refactor: fix library creation

* fix: paper shadow

* fix: e2e
2026-01-23 18:49:35 -04:00
Gabriel Moreno
48327532c5 More Data Studio UX improvements & QoL fixes (#68546)
* fix(UXW-2752): remove unwanted padding on data structure fields tab group

* fix(UXW-2707): remove double border on job transforms section

* feat(UXW-2718): add horizontal layout for schedule controls

* feat(UXW-2709): fix spacing in the segments empty state

* fix(UXW-2780): long text overflow in user input

* fix(UXW-2710): set proper placeholder text on empty python library first load

* feat(UXW-2767): rename 'New collection' to 'Transform folder'

* refactor: blow grid & use `Ellipsified` instead
2026-01-23 18:03:17 -04:00
Anderson
26f71cfeb5 Fix SQL editor scrolling on Transform pages (#68107)
* Fix SQL editor scrolling on Transform pages

* Fix e2e test for question editor

* Fix python transform scrolling

* Fix lint

* Fix view-only transform content height
2026-01-23 18:42:24 -03:00
Braden Shepherdson
3d4e44b776 [deps] After a sync, re-analyze all dependents of updated tables (#68621)
Closes QUE-2984.

Adds an event handler for when a DWH sync is completed, which checks all its tables and fields for
their latest update times, and re-analyzes all cards which depend on tables updated since the last analysis.
2026-01-23 21:01:41 +00:00
Brad Anderson
86f3e524ba Fix UXW-2759 - Snippet edits in data studio are not saved (#68565) 2026-01-23 15:29:42 -05:00
Nathan Voxland
056d61238e If a dashboard is back to just one tab, auto-assign all cards to that tab (#68201) 2026-01-23 14:22:12 -06:00
Maksym Yakubych
926610da14 fix: custom Logo isn't loaded properly on User Invite Email (#68476) 2026-01-23 11:44:43 -08:00
Alexander Polyankin
75251f933a Fix text wrapping in dependencies (#68615) 2026-01-23 19:28:48 +00:00
Nemanja Glumac
d959d54aec Remove cache cleanup CI action (#68521) 2026-01-23 20:25:23 +01:00
Edward Paget
0933b7378f feat: support remote-syncing measures (#68392)
* feat: support remote-syncing measures

Add remote-sync support for measures, following the same pattern as segments:

- Add :model/Measure spec to remote-sync-specs with hybrid identity,
  parent-table eligibility, and event tracking
- Update measure permissions to check remote-sync/table-editable? for
  can-write? and can-create? (replacing superuser-only derive)
- Add batched hydration for :can_write on measures
- Add measures to serdes/descendants for Table so they export with parent
- Add tests for measure import/export tracking in impl_test.clj

On the Frontend creates a new data-driven approach to setting up the
middleware listener for changes that cause us to refresh dirty-state.

* fixup! typecheck
2026-01-23 13:17:01 -06:00
Edward Paget
e3b200b319 fix: flakey ec keypair test (#68591)
* fix: flakey ec keypair test

* fixup! do crypto in a delay
2026-01-23 13:15:00 -06:00
Edward Paget
fb9c0e4499 fix: derive events remote-sync needs in oss code (#68590) 2026-01-23 13:14:51 -06:00
Nemanja Glumac
40522b6cba Remove unused lodash types (#68593) 2026-01-23 19:26:18 +01:00
metamben
b6edd15de2 Stop reporting errors on archived or inaccessible entities (#68613)
Closes QUE-2999
2026-01-23 12:52:32 -05:00
lbrdnk
16042f45c7 Use macaw options, add parsed-query wrapped (#68466)
* Hoist macaw impl from query analyzer

* Use macaw-opts in bigquery module

* Use macaw-options in sqlserver

* Use macaw-options in sql driver

* Remove require

* Comment

* Test

* Update test

* Update macaw version

* Best effort support for bigquery

* Make test driver specific

* Remove final-not-reserved

* Not allowed on clickhouse only

* Clickhouse exception test

* best effort opts on api

* Remove final-non-reserved

* Remove requires

* driver.u/parsed-query

* Use parsed-query

* parsed-query

* ch test

* throw

* Lint against incorrect Macaw usage (#68568)

* Remove now unused function

* Shutdown loud exceptions for now

* Shutdown ch test

* Update test

---------

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-01-23 18:37:06 +01:00
Riley Thompson
b28ab0ec1d Revert ClickHouse from 0.9.5 to 0.8.4 (#68502)
* revert to 0.8.4

* disable test
2026-01-23 17:18:22 +00:00
github-actions[bot]
add8906504 Fix: Keyboard navigation annoyance in command palette (#67317)
Co-authored-by: Claude Code[bot] <noreply@anthropic.com>
Co-authored-by: Brad Anderson <brad@metabase.com>
2026-01-23 11:33:33 -05:00
dpsutton
3345365546 handle empty error message (#68581)
Fixes https://github.com/metabase/metabase/issues/68578

This issue is not about the stack overflow per-se but blowing up on a
string includes:

```javascript
{
  "app": "metabase",
  "time": "2026-01-22T12:08:53.660601485Z",
  "stream": "stderr",
  "app_version": "v1.57.10-X01",
  "message": "Exception in thread \"Thread-41\" java.lang.NullPointerException: Cannot invoke \"Object.toString()\" because \"s\" is null
at clojure.string$includes_QMARK_.invokeStatic(string.clj:377)
at clojure.string$includes_QMARK_.invoke(string.clj:373)
at metabase.app_db.cluster_lock$retryable_QMARK_.invokeStatic(cluster_lock.clj:27)
at metabase.app_db.cluster_lock$retryable_QMARK_.invoke(cluster_lock.clj:19)
at metabase.app_db.cluster_lock$do_with_cluster_lock.invokeStatic(cluster_lock.clj:87)
at metabase.app_db.cluster_lock$do_with_cluster_lock.invoke(cluster_lock.clj:65)
at metabase.app_db.cluster_lock$do_with_cluster_lock.invokeStatic(cluster_lock.clj:79)
at metabase.app_db.cluster_lock$do_with_cluster_lock.invoke(cluster_lock.clj:65)
at metabase.search.task.search_index$init_BANG_.invokeStatic(search_index.clj:36)
at metabase.search.task.search_index$init_BANG_.invoke(search_index.clj:32)
at clojure.lang.AFn.run(AFn.java:22)
at java.base/java.lang.Thread.run(Unknown Source)",
  "namespace": "hosting-eb9b3875e12d465a",
  "container": "metabase",
  "cluster": "metabase-1",
  "_p": "F"
}
```

The error message itself comes from a stackoverflow which perhaps does
not have a message? Either way, it's possible for this to be missing so
be a bit defensive
2026-01-23 10:04:56 -06:00
Sébastien
862dc50c64 Fix ChartTypeDropdown overflowing (#68455)
* Forcing overflow policy on ChartTypeDropdown

* tpyo

* refactoring to enable testing this with loki

* fixed story

* loki

* screenshot

* removed useless unit test
2026-01-23 16:08:37 +01:00
Nicola Mometto
0941091421 ensure common.py library is always available (#68603)
* fix: ensure common python library is always defined

* chore: e2e test

* fix: empty rollback

* fix: empty source

* fix: text
2026-01-23 15:01:18 +00:00
Alexander Polyankin
0bd69823a4 Show view_count for models and metrics in dependencies (#68588) 2026-01-23 17:42:57 +03:00
Alexander Polyankin
6a01533ca7 Use MBQL lib for matching field refs in ValueSourceModal in parameters (#68431) 2026-01-23 17:42:06 +03:00
Nemanja Glumac
7d18a1b5a6 Unset the premium token for E2E tests (#68533) 2026-01-23 13:54:21 +01:00
github-automation-metabase
0bf7e41984 Auto-update documentation for master (#68589)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-01-23 07:47:03 -05:00
Sébastien
95a25e4a3e fix proper bg color for embedded dashboards with custom theme (#68539) 2026-01-23 13:00:22 +01:00
Dan Stone
a2c4eef9de Revert the revert of column validation for incremental transforms (#68460)
Filter extract-columns endpoint to only return numeric and temporal columns
The extract-columns-from-query function is used to populate the incremental
filter dropdown for native SQL transforms. Previously it returned all columns
without filtering on type, leading to undefined behaviour when unsupported
column types were selected.

This change filters the results to only include columns with types supported
for incremental checkpointing:
- Temporal types (timestamp, timestamp with timezone)
- Numeric types (integer, float, decimal)

Text, boolean, and other unsupported types are now filtered out, matching
the behaviour of MBQL and Python transforms which already filter columns
client-side.

Updated documentation to clarify this endpoint is specifically for incremental
transform checkpoint column selection.

Also validates that transform POST/PUT conform to this rule and you are unable to update the transform to have
a filter column of an unsupported type.

Reverts the revert of https://github.com/metabase/metabase/pull/67686
2026-01-23 11:24:09 +00:00
Chris Truter
2f81937643 Fix data format for TODOs in style guides (#68598) 2026-01-23 13:13:30 +02:00
Sloan Sparger
48bf58b162 Cleanup from #68433 (#68481)
fix ai feedback that came after PR close :'/
2026-01-23 02:33:48 +00:00
Egor Grushin
761a7cf01e GDGT 1533 improve discoverability of python library in transforms UI (#68523) 2026-01-23 00:55:37 +00:00
Timothy S. Dean
8a76f71003 Remove find-column-for-name (#68382)
Remove find-column-for-name
2026-01-22 16:06:41 -07:00
Maksym Yakubych
fd90d13ae3 fix: custom brand color not consistently applied on Enterprise UI elements (#68484) 2026-01-22 22:56:00 +00:00
Maz Ameli
a62aab3f1b Rename Data Studio Library +New menu action labels (#68162)
* Rename Data Studio Library +New menu action labels

Update the menu item labels in the Data Studio Library's +New button:
- "Publish a table" → "Published table"
- "New snippet" → "Snippet"
- "New snippet folder" → "Snippet folder"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* prettier

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:39:54 +00:00
metamben
bafd49ee17 Add options to the /graph/dependents endpoint (#68573) 2026-01-22 21:58:19 +00:00
Edward Paget
1f0b461d6f feat: support remote sync transforms in FE (#68306)
* feat: support remote sync transforms in FE

1. Updates the DataStudioLayout to show transform dirty state

   We get the orange dot next to Transforms if transforms or transform
   collections are dirty.

2. Update the all changes view to include transforms

   Also includes their collection heirarchy if the transforms sync
   setting is enabled

3. Add the transforms sync setting

   To both the remote sync admin page and the Set up git sync modal in
   the data studio

* fixup! refresh transforms after sync

* fixup! data studio test feedback

* fixup! refactor failing test to troubleshoot debugging
2026-01-22 14:33:00 -06:00
metamben
1712274b85 Normalize transforms metadata (#68490) 2026-01-22 23:22:39 +03:00
Timofey Kachalov
ea203102e6 Don't show CORS settings in OSS/Starter editions (#68564) 2026-01-23 00:14:05 +04:00
Edward Paget
3c72591b17 feat: add Slack Connect SSO provider (#67955)
* feat: add Slack Connect SSO provider

Add enterprise Slack Connect SSO integration using the OIDC infrastructure:

- Slack Connect provider: OAuth2/OIDC flow for Slack workspace authentication
- Settings: configuration options for Slack client ID, secret, and auth modes
- User provisioning: automatic user creation from Slack identity
- API endpoints: SSO initiation and callback handling
- Analytics: usage tracking for Slack Connect feature

Slack Connect allows users to authenticate via their Slack workspace,
with support for both SSO-only and link-account authentication modes.

* fix: session reset after auth

* fixup! avoid email clash

* fixup! wrong value in sso settings

* fixup! cleanup tests

* fixup! fix test

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-01-22 10:57:12 -08:00
github-automation-metabase
9a19a5e4f0 [chore] Update llms.txt for master (#68561)
Update llms.txt and llms-full.txt (master)

Regenerated llms.txt to include all markdown files from the docs/ directory.
Also generated llms-full.txt files for the embedding section.

🤖 Generated automatically by llms-txt-generate workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-01-22 18:20:04 +00:00
Sloan Sparger
f387d4aca4 [Metabot] Add close button back to data studio sidebar (#68487)
allow metabot to be closed in data studio
2026-01-22 11:33:21 -06:00
github-automation-metabase
1bb6f6a74a Update translations 2026-01-22 (#68557)
* Update translation sources 2026-01-22

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-22 17:19:01 +00:00
Nicolò Pretto
2ad4292465 fix(embed-homepage): rename static embed to guest embed (#68338) 2026-01-22 16:37:02 +00:00
github-automation-metabase
37c3758748 Auto-update documentation for master (#68524)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-01-22 09:26:48 -05:00
Jeff Bruemmer
ae9cad5c58 docs - note on ee vs oss path (#68210) 2026-01-22 09:09:05 -05:00
John Swanson
bd14e14974 Reenable deactivated tenants when autoprovisioning (#67947)
Previously even when autoprovisioning was on, you could not log into a
deactivated tenant (either as a new user or an existing user with that
tenant).

This changes the behavior to be more consistent. Just as a deactivated
user can log in when provisioning is on (and their user gets
reactivated), a deactivated tenant's users can still log in, but doing
so reactivates the tenant.
2026-01-22 05:10:23 -08:00
Jakub Chodorowicz
85319217cd Gdgt 1273 add snowplow events to bulk metadata features (#68409)
* add data studio library filtering events

* add bulk properties and unpublish event

* add bulk sync events

* add search event

* add assertions on events in e2e tests

* add search debounce

* remove unncessery comment

* update event names

* track filter searches with data_studio_table_picker_search_performed

* refactor tracking function

* guard and log event only for ConcreteTableId

* log search when started fetching

* fix events not fired

* fix event not fired on first search
2026-01-22 12:30:26 +01:00
Nicola Mometto
240acb4782 add started-at filter to task runs page (#68279)
* WIP: add started-at filter

* fix: kondo

* fix tests

* GDGT-1520 add a started at filter

* GDGT-1520 add a comment

* GDGT-1520 fix typing issues

* GDGT-1520 addressing PR comments

* GDGT-1520 addressing PR comments

* GDGT-1520 fix a tests after changing a text

---------

Co-authored-by: Egor Grushin <egor@metabase.com>
2026-01-22 09:49:15 +00:00
Jakub Chodorowicz
e6bc40c223 add analytics skill (#68419) 2026-01-22 09:36:42 +01:00
bryan
76fcb4d573 Fix transforms module to trigger driver tests + promote pg (#68432) 2026-01-21 16:30:15 -07:00
Arkadi Yaroslavtsev
a92317dd27 Add Anchor Links to Documents (#67940)
* Add AnchorLinkButton component for document anchor links

- Add AnchorLinkButton component that copies URL to clipboard on click
- Shows 'Copied!' tooltip feedback using Mantine useClipboard hook
- Follows same pattern as CommentsButton for consistent styling
- Add unit tests for the component

* Add AnchorLinkMenu component for left-side anchor link hover

- Add AnchorLinkMenu floating menu for document blocks
- Uses createPortal for positioning (same pattern as CommentsMenu)
- Positioned on left side of blocks using floating-ui
- Shows/hides based on hover state via show prop
- Add unit tests for the component

* Add AnchorLinkMenu to document node views

- Add documentWithAnchor URL helper to generate shareable anchor URLs
- Update Heading, Paragraph, and Blockquote node views to render AnchorLinkMenu
- AnchorLinkMenu appears on the left side on hover
- CommentsMenu on the right side remains unchanged
- Each node view now uses two useFloating instances for positioning

* Add scroll-to-anchor for document URLs with hash

- Add E2E tests for anchor links functionality
- Implement scroll-to-anchor when navigating to document with #blockId hash
- Tests cover: hover shows anchor icon, copy to clipboard, scroll to block
- Regression test ensures CommentsMenu still works on right side

* Fix scroll-to-anchor dependency arrays for navigation edge cases

- Add documentId to scroll effect dependencies to re-run when navigating
  between documents with the same anchor hash
- Add location.hash to reset effect dependencies to properly reset flag
  when navigating via back button or changing anchors

* Fix accidental logo replacement and E2E blockquote test

- Restore original Metabase logo SVG (was replaced with red star debug graphic)
- Fix E2E test: use find('blockquote') instead of findByRole('blockquote')
  since native blockquote elements have no implicit ARIA role

* Fix scroll-to-anchor reliability and empty line behavior

- Implement retry mechanism for scroll-to-anchor to ensure target element exists
- Add highlight animation when scrolling to anchor
- Hide anchor link and comment menus on empty lines (Heading, Paragraph, Blockquote)

* Fix selector mismatch for scroll-to-anchor

- Add data-node-id attribute to NodeViewWrappers in Heading, Paragraph, and Blockquote
  extensions so they can be queried by DocumentPage
- Update DocumentPage to query using [data-node-id] selector instead of internal _id attribute
- Remove unused ID_ATTRIBUTE_NAME import in DocumentPage

* Fix highlight animation color variable

- Replace non-existent --mb-color-brand-alpha-25 with --mb-color-brand-light
- Ensure highlight animation is visible when scrolling to anchor

* added anchor links to lists and code blocks. some bugs fixes.

* make sure scroll-to-anchor observer is correctly initialized

- Refactor scroll-to-anchor logic to ensure smoother navigation
- Optimize highlight animation visibility during scrolling
- Adjust anchor link behavior on empty lines for better user experience

* small fixes

* clean up

* fixes and cleanup

* e2e test fixes

* fixed e2e test

* Leave anchor links only for header blocks

* Fix alignment

* Review fixes

* Fix unit tests

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2026-01-22 02:03:04 +03:00
bryan
087a317f70 Fix ci-report output formatting issues (#68505) 2026-01-21 15:50:57 -07:00
Denis Berezin
c63fd746d8 Uxw 2633 copy cells from table (#68429)
* UXW-2633 fix sorted table values copy-paste

* Add e2e test
2026-01-22 01:24:12 +03:00
dpsutton
f29a446aab fix materialized views test (#68509)
https://github.com/metabase/metabase/pull/68190 did:

> Treats a 0 value of n_live_tup as NULL, due to confusion reported when
> auto analyze thresholds have not been met on a table.

I think what's happening is this is trying to suss out "i don't know"
and not give misleading information. When there are no stats pg would
report there are 0 rows rather than saying i don't know. So we toss away
these unreliable 0 count estimations.

This test flakes 7% of the time. Our hypothesis is that stats are empty
7% of the time so this row count estimation now gets removed.

By default the schema is `:estimated_row_count (mt/malli=? [:maybe
:int])` which was being clobbered with
`{:estimated_row_count (mt/malli=? int?)}` So the fix is to just not
clobber
2026-01-21 16:07:45 -06:00
Edward Paget
d26bf4e454 feat: support transforms in remote sync (#68255)
* feat: support transforms in remote sync

1. Serialize Transforms as Collection Items

   Transforms were previously serialized as a top-level item, but this
   moves them to be serialized as a child of the collection they are
   member of. This also fixes an error in their serdes/dependencies
   implementation where it did not include the parent collection leading
   to deserialization issues.

2. Enforce Remote-Sync write rules

   Adds implementations of the model interface permissions methods for
   transforms that enforce the can-write? = false and can-create? =
   false rules when remote-sync is in read-only mode.

3. Add a setting to control whether transforms are synced

   Transforms are globally synced not synced via collections, so this
   adds a new boolean setting that controls whether they are synced on
   instances in read-write mode. Like all other content transforms are
   always synced from the remote source if the instance is in read-only
   mode.

4. Add a new spec-based configuration system for models

   Models we want to make remote-syncable have a complicated, but still
   data-driven system to allow you to specify their remote-sync behavior
   and have that automatically setup in a single place the
   `metabase-enterprise.remote-sync.spec` namespace.

* fixup! test with wrong folder

* fixup! not handling archived transform collections

* fixup! clean up aliases
2026-01-21 15:29:27 -06:00
Denis Berezin
7aa18ae448 Fix flaky test that uses readonly db that lacks permissions (#68417) 2026-01-22 00:15:27 +03:00
Timothy S. Dean
97da3d7c7b Migration follow-up fix to #68100 (#68498)
Move a migration from the v59 file to the v56 file, as we intend to backport it that far.
2026-01-21 14:15:12 -07:00
Aleksandr Lesnenko
10e8a68cf5 Data Analysts user group (#67770)
* basic analyst role

* analyst role

* Backend for data analyst role

* Fixup frontend to work with Backend

The dedicated analyst endpoints have been removed:
- `GET /api/user/analysts` -> Use `GET /api/user?is_data_analyst=true`
- `POST /api/user/analysts/:id` -> Use `PUT /api/user/:id` with `{ is_data_analyst: true }`
- `DELETE /api/user/analysts/:id` -> Use `PUT /api/user/:id` with `{ is_data_analyst: false }`

These hooks no longer exist:
- useListAnalystsQuery
- useAddAnalystMutation
- useRemoveAnalystMutation

New Patterns:

Listing analysts:
```
// Before
const { data } = useListAnalystsQuery();

// After
const { data } = useListUsersQuery({ is_data_analyst: true });
```

Adding an analyst:
```
// Before
await addAnalyst(userId).unwrap();

// After
await updateUser({ id: userId, is_data_analyst: true }).unwrap();
```

Removing an analyst:
```
// Before
await removeAnalyst(userId).unwrap();

// After
await updateUser({ id: userId, is_data_analyst: false }).unwrap();
```

Type Changes:

The field is_analyst has been renamed to is_data_analyst everywhere:

- User.is_data_analyst (was is_analyst)
- UpdateUserRequest.is_data_analyst (was is_analyst)
- ListUsersRequest.is_data_analyst (new field for filtering)

* kondo

* circular dep

* fix backend not compiling

* use the actual role

* analyst management, glossary access for admins

* various data studio access

* access to library

* drop custom analyst seats management ui

* automatic perms group

* fix tests

* specs

* permissions

* fix lints, add migration rollbacks

* data analysts can ALWAYS view table metadata

implemented for:

- search
- `table/can-read?`

* Also remove `can_access_data_model`

* Revert "Also remove `can_access_data_model`"

This reverts commit 03af432ccb.

* fix more tests

* more tests

* snapshots

* remove redundant checks

* everyone can see

* hide data analyst group for OSS

* Make data analysts a normal group on OSS

* Automatically provide data analysts with perms

Data analysts automatically get manage-table-metadata permissions on
every table/database.

* only disable setting some perms for data analysts

* kondo

* oops, one more kondo

* test fixes

* specs, fixes

* more specs, fixes

* cleanup

* more test fixes

* fix module config

* ci

* doh, fix test

* more test whack-a-mole

* move data analyst group to the top of perms views

* review

* ci

* Turn DA into a regular group when feature missing

instead of just on OSS.

The goal is:
- OSS: a previously existing Data Analysts group is a regular group with
configurable permissions. The real Data Analysts group is invisible and
has no members.
- EE without the token: same as above
- EE with the token: the real Data Analysts group is visible.

* oops, remove unnecessary require

* another one

* fix membership API

* fix a couple more tests

* macro gets a !

* fix `fetch-groups-test`

* ci

* ci

* ci

* ci

* fix collection perm in migration

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
2026-01-21 12:43:35 -08:00
Dan Meyer
209a34b102 ci: Enable fail-fast for BigQuery and Snowflake driver tests (#68199)
Add :fail-fast? true to BigQuery and Snowflake test-args, matching the
existing Redshift configuration. This stops test execution on first
failure, saving CI time and resources.
2026-01-21 20:27:03 +00:00
William
a6df84c136 Bump toucan2 version to fix dashboards sometimes failing to load (#68494)
If the first table mentioned by a dashboard has no fields (presumably because the table and its field are all
inactive), /api/dashboard/<id>/query_metadata would fail due to a toucan2 bug.  This bumps toucan2 to fix the bug and
adds a test to verify that this case now works.
2026-01-21 20:18:30 +00:00
Riley Thompson
adb9a5cbf4 Bump dependency analysis version (#68488)
bump dependency analysis version
2026-01-21 19:08:45 +00:00
558 changed files with 126813 additions and 108097 deletions

View File

@@ -29,7 +29,7 @@ This guide covers Clojure and ClojureScript coding conventions for Metabase. See
**Comments:**
- `TODO` format: `;; TODO (Name M/D/YY) -- description`
- `TODO` format: `;; TODO (Name YYYY-MM-DD) -- description`
## Code Organization

View File

@@ -0,0 +1,504 @@
---
name: analytics-events
description: Add product analytics events to track user interactions in the Metabase frontend
allowed-tools: Read, Write, Edit, Grep, Glob
---
# Frontend Analytics Events Skill
This skill helps you add product analytics (Snowplow) events to track user interactions in the Metabase frontend codebase.
## Quick Reference
Analytics events in Metabase use Snowplow with typed event schemas. All events must be defined in TypeScript types before use.
**Key Files:**
- `frontend/src/metabase-types/analytics/event.ts` - Event type definitions
- `frontend/src/metabase-types/analytics/schema.ts` - Schema registry
- `frontend/src/metabase/lib/analytics.ts` - Core tracking functions
- Feature-specific `analytics.ts` files - Tracking function wrappers
## Quick Checklist
When adding a new analytics event:
- [ ] Define event type in `frontend/src/metabase-types/analytics/event.ts`
- [ ] Add event to appropriate union type (e.g., `DataStudioEvent`, `SimpleEvent`)
- [ ] Create tracking function in feature's `analytics.ts` file
- [ ] Import and call tracking function at the interaction point
- [ ] Use `trackSimpleEvent()` for basic events (most common)
## Event Schema Types
### 1. Simple Events (Most Common)
Use `SimpleEventSchema` for straightforward tracking. It supports these standard fields:
```typescript
type SimpleEventSchema = {
event: string; // Required: Event name (snake_case)
target_id?: number | null; // Optional: ID of affected entity
triggered_from?: string | null; // Optional: UI location/context
duration_ms?: number | null; // Optional: Duration in milliseconds
result?: string | null; // Optional: Outcome (e.g., "success", "failure")
event_detail?: string | null; // Optional: Additional detail/variant
};
```
**When to use:** 90% of events fit this schema. Use for clicks, opens, closes, creates, deletes, etc.
### 2. Custom Schemas (legacy, no events are being added)
Consider adding new event schema only in very special cases.
**Examples:** `DashboardEventSchema`, `CleanupEventSchema`, `QuestionEventSchema`
## Step-by-Step: Adding a Simple Event
### Example: Track when a user applies filters in a table picker
#### Step 1: Define Event Types
Add event type definitions to `frontend/src/metabase-types/analytics/event.ts`:
```typescript
export type DataStudioTablePickerFiltersAppliedEvent = ValidateEvent<{
event: "data_studio_table_picker_filters_applied";
}>;
export type DataStudioTablePickerFiltersClearedEvent = ValidateEvent<{
event: "data_studio_table_picker_filters_cleared";
}>;
```
#### Step 2: Add to Union Type
Find or create the appropriate union type and add your events:
```typescript
export type DataStudioEvent =
| DataStudioLibraryCreatedEvent
| DataStudioTablePublishedEvent
| DataStudioGlossaryCreatedEvent
| DataStudioGlossaryEditedEvent
| DataStudioGlossaryDeletedEvent
| DataStudioTablePickerFiltersAppliedEvent // <- Add here
| DataStudioTablePickerFiltersClearedEvent; // <- Add here
```
#### Step 3: Create Tracking Functions
In your feature's `analytics.ts` file (e.g., `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`):
```typescript
import { trackSimpleEvent } from "metabase/lib/analytics";
export const trackDataStudioTablePickerFiltersApplied = () => {
trackSimpleEvent({
event: "data_studio_table_picker_filters_applied",
});
};
export const trackDataStudioTablePickerFiltersCleared = () => {
trackSimpleEvent({
event: "data_studio_table_picker_filters_cleared",
});
};
```
#### Step 4: Use in Components
Import and call the tracking function at the interaction point:
```typescript
import {
trackDataStudioTablePickerFiltersApplied,
trackDataStudioTablePickerFiltersCleared,
} from "metabase-enterprise/data-studio/analytics";
function FilterPopover({ filters, onSubmit }) {
const handleReset = () => {
trackDataStudioTablePickerFiltersCleared(); // <- Track here
onSubmit(emptyFilters);
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
trackDataStudioTablePickerFiltersApplied(); // <- Track here
onSubmit(form);
}}
>
{/* form content */}
</form>
);
}
```
## Using SimpleEventSchema Fields
### Example: Event with target_id
```typescript
// Type definition
export type DataStudioLibraryCreatedEvent = ValidateEvent<{
event: "data_studio_library_created";
target_id: number | null;
}>;
// Tracking function
export const trackDataStudioLibraryCreated = (id: CollectionId) => {
trackSimpleEvent({
event: "data_studio_library_created",
target_id: Number(id),
});
};
// Usage
trackDataStudioLibraryCreated(newLibrary.id);
```
### Example: Event with triggered_from
```typescript
// Type definition
export type NewButtonClickedEvent = ValidateEvent<{
event: "new_button_clicked";
triggered_from: "app-bar" | "empty-collection";
}>;
// Tracking function
export const trackNewButtonClicked = (location: "app-bar" | "empty-collection") => {
trackSimpleEvent({
event: "new_button_clicked",
triggered_from: location,
});
};
// Usage
<Button onClick={() => {
trackNewButtonClicked("app-bar");
handleCreate();
}}>
New
</Button>
```
### Example: Event with event_detail
```typescript
// Type definition
export type MetadataEditEvent = ValidateEvent<{
event: "metadata_edited";
event_detail: "type_casting" | "semantic_type_change" | "visibility_change";
triggered_from: "admin" | "data_studio";
}>;
// Tracking function
export const trackMetadataChange = (
detail: "type_casting" | "semantic_type_change" | "visibility_change",
location: "admin" | "data_studio"
) => {
trackSimpleEvent({
event: "metadata_edited",
event_detail: detail,
triggered_from: location,
});
};
// Usage
trackMetadataChange("semantic_type_change", "data_studio");
```
### Example: Event with result and duration
```typescript
// Type definition
export type MoveToTrashEvent = ValidateEvent<{
event: "moved-to-trash";
target_id: number | null;
triggered_from: "collection" | "detail_page" | "cleanup_modal";
duration_ms: number | null;
result: "success" | "failure";
event_detail: "question" | "model" | "metric" | "dashboard";
}>;
// Tracking function
export const trackMoveToTrash = (params: {
targetId: number | null;
triggeredFrom: "collection" | "detail_page" | "cleanup_modal";
durationMs: number | null;
result: "success" | "failure";
itemType: "question" | "model" | "metric" | "dashboard";
}) => {
trackSimpleEvent({
event: "moved-to-trash",
target_id: params.targetId,
triggered_from: params.triggeredFrom,
duration_ms: params.durationMs,
result: params.result,
event_detail: params.itemType,
});
};
// Usage with timing
const startTime = Date.now();
try {
await moveToTrash(item);
trackMoveToTrash({
targetId: item.id,
triggeredFrom: "collection",
durationMs: Date.now() - startTime,
result: "success",
itemType: "question",
});
} catch (error) {
trackMoveToTrash({
targetId: item.id,
triggeredFrom: "collection",
durationMs: Date.now() - startTime,
result: "failure",
itemType: "question",
});
}
```
## Naming Conventions
### Event Names (snake_case)
```typescript
// Good
"data_studio_library_created"
"table_picker_filters_applied"
"metabot_chat_opened"
// Bad
"DataStudioLibraryCreated" // Wrong case
"tablePickerFiltersApplied" // Wrong case
"filters-applied" // Use underscore, not hyphen
```
### Event Type Names (PascalCase with "Event" suffix)
```typescript
// Good
DataStudioLibraryCreatedEvent
TablePickerFiltersAppliedEvent
MetabotChatOpenedEvent
// Bad
dataStudioLibraryCreated // Wrong case
DataStudioLibraryCreated // Missing "Event" suffix
```
### Tracking Function Names (camelCase with "track" prefix)
```typescript
// Good
trackDataStudioLibraryCreated
trackTablePickerFiltersApplied
trackMetabotChatOpened
// Bad
DataStudioLibraryCreated // Missing "track" prefix
track_library_created // Wrong case
logLibraryCreated // Use "track" prefix
```
## Common Patterns
### Pattern 1: Feature-Specific Union Types
Group related events together:
```typescript
export type DataStudioEvent =
| DataStudioLibraryCreatedEvent
| DataStudioTablePublishedEvent
| DataStudioGlossaryCreatedEvent;
export type MetabotEvent =
| MetabotChatOpenedEvent
| MetabotRequestSentEvent
| MetabotFixQueryClickedEvent;
// Then add to SimpleEvent union
export type SimpleEvent =
| /* other events */
| DataStudioEvent
| MetabotEvent
| /* more events */;
```
### Pattern 2: Conditional Tracking
Track different events based on user action:
```typescript
const handleSave = async () => {
if (isNewItem) {
await createItem(data);
trackItemCreated(newItem.id);
} else {
await updateItem(id, data);
trackItemUpdated(id);
}
};
```
## Common Pitfalls
### Don't: Add custom fields to SimpleEvent
```typescript
// WRONG - SimpleEvent doesn't support custom fields
export const trackFiltersApplied = (filters: FilterState) => {
trackSimpleEvent({
event: "filters_applied",
data_layer: filters.dataLayer, // ❌ Not in SimpleEventSchema
data_source: filters.dataSource, // ❌ Not in SimpleEventSchema
with_owner: filters.hasOwner, // ❌ Not in SimpleEventSchema
});
};
// RIGHT - Use only standard SimpleEventSchema fields
export const trackFiltersApplied = () => {
trackSimpleEvent({
event: "filters_applied",
});
};
// Or use event_detail for a single variant
export const trackFilterApplied = (filterType: string) => {
trackSimpleEvent({
event: "filter_applied",
event_detail: filterType, // ✓ "data_layer", "data_source", etc.
});
};
```
### Don't: Forget to add event to union type
```typescript
// Define the event
export type NewFeatureClickedEvent = ValidateEvent<{
event: "new_feature_clicked";
}>;
// ❌ WRONG - Forgot to add to SimpleEvent union
// Event won't be recognized by TypeScript
// ✓ RIGHT - Add to appropriate union
export type SimpleEvent =
| /* other events */
| NewFeatureClickedEvent;
```
### Don't: Mix up event name formats
```typescript
// WRONG
event: "dataStudioLibraryCreated" // camelCase
event: "data-studio-library-created" // kebab-case
event: "Data_Studio_Library_Created" // Mixed case
// RIGHT
event: "data_studio_library_created" // snake_case
```
### Don't: Track PII or sensitive data
```typescript
// WRONG - Don't track user emails, names, or sensitive data
trackSimpleEvent({
event: "user_logged_in",
event_detail: user.email, // ❌ PII
});
// RIGHT - Track non-sensitive identifiers only
trackSimpleEvent({
event: "user_logged_in",
target_id: user.id, // ✓ Just the ID
});
```
### Don't: Forget to track both success and failure
```typescript
// WRONG - Only tracking success
try {
await saveData();
trackDataSaved();
} catch (error) {
// ❌ No tracking for failure case
}
// RIGHT - Track both outcomes
try {
await saveData();
trackDataSaved({ result: "success" });
} catch (error) {
trackDataSaved({ result: "failure" });
}
```
## Testing Analytics Events
While developing, you can verify events are firing:
1. **Check browser console** - When `SNOWPLOW_ENABLED=true` in dev, events are logged
2. **Use shouldLogAnalytics** - Set in `metabase/env` to see all analytics in console
3. **Check Snowplow debugger** - Browser extension for Snowplow events
Example console output:
```
[SNOWPLOW EVENT | event sent:true], data_studio_table_picker_filters_applied
```
## File Organization
### Where to put tracking functions:
```
Feature-specific analytics functions:
frontend/src/metabase/{feature}/analytics.ts
enterprise/frontend/src/metabase-enterprise/{feature}/analytics.ts
Event type definitions (all in one place):
frontend/src/metabase-types/analytics/event.ts
Core tracking utilities:
frontend/src/metabase/lib/analytics.ts
```
## Real-World Examples
See these files for reference:
- **Simple events**: `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`
- **Events with variants**: `frontend/src/metabase/dashboard/analytics.ts`
- **Complex events**: `frontend/src/metabase/query_builder/analytics.js`
- **Event type examples**: `frontend/src/metabase-types/analytics/event.ts`
## Workflow Summary
1. **Identify the user interaction** to track
2. **Decide on event name** (snake_case, descriptive)
3. **Define event type** in `event.ts` using `ValidateEvent`
4. **Add to union type** (create feature union if needed)
5. **Create tracking function** in feature's `analytics.ts`
6. **Import and call** at the interaction point
7. **Test** that events fire correctly
## Tips
- **Be specific** - `filters_applied` is better than `action_performed`
- **Use past tense** - `library_created` not `create_library`
- **Group related events** - Create feature-specific event union types
- **Track meaningful actions** - Not every click needs tracking
- **Consider the data** - What would you want to analyze later?
- **Stay consistent** - Follow existing naming patterns in the codebase
- **Document context** - Use `triggered_from` to track where the action happened

View File

@@ -54,7 +54,7 @@ Use this to scan through changes efficiently:
- [ ] Public vars in `src` or `enterprise/backend/src` have useful docstrings
- [ ] Docstrings use Markdown conventions
- [ ] References use `[[other-var]]` not backticks
- [ ] `TODO` comments include author and date: `;; TODO (Name 1/1/25) -- description`
- [ ] `TODO` comments include author and date: `;; TODO (Name 2025-01-01) -- description`
### Code Organization
@@ -118,7 +118,7 @@ Quick scan for common issues:
| `update-db`, `save-model` | Missing `!` for side effects: `update-db!`, `save-model!` |
| `snake_case_var` | Should use kebab-case |
| Public var without docstring | Add docstring explaining purpose |
| `;; TODO fix this` | Missing author/date: `;; TODO (Name 1/1/25) -- description` |
| `;; TODO fix this` | Missing author/date: `;; TODO (Name 2025-01-01) -- description` |
| `(defn foo ...)` in namespace used elsewhere | Should be `(defn ^:private foo ...)` |
| Function > 20 lines | Consider breaking up into smaller functions |
| `/api/dashboards/:id` | Use singular: `/api/dashboard/:id` |

View File

@@ -65,6 +65,7 @@
:metabase/missing-test-expr-requires-in-cljs {:level :warning}
:metabase/modules {:level :warning} ; more config in `.clj-kondo/config/modules/config.edn`
:metabase/namespace-name {:level :warning}
:metabase/no-jsqlparser-imports {:level :warning}
:metabase/require-shape-checker {:level :warning}
:metabase/test-helpers-use-non-thread-safe-functions {:level :warning} ; more config in `.clj-kondo/config/test-helpers/config.edn`
:metabase/tests-must-live-in-known-modules {:level :warning}
@@ -299,7 +300,8 @@
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"}
macaw.core/parsed-query {:message "Use metabase.driver.util/parsed-query instead of macaw.core/parsed-query"}}
:discouraged-namespace
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
@@ -522,8 +524,10 @@
metabase-enterprise.advanced-permissions.models.permissions.application-permissions-test/with-new-group-and-current-graph hooks.common/with-two-top-level-bindings
metabase-enterprise.audit-app.pages-test/with-temp-objects hooks.common/with-one-binding
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
metabase-enterprise.transforms-python.drivers-test/with-test-table hooks.common/with-two-bindings
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding

View File

@@ -383,7 +383,6 @@
:uses #{api
app-db
collections
config
eid-translation
events
lib-be
@@ -809,6 +808,7 @@
lib-be
models
permissions
remote-sync
search
util}}
@@ -1456,6 +1456,7 @@
models
permissions
premium-features
query-processor
request
task
util}}
@@ -1499,7 +1500,6 @@
:uses #{analytics
api
collections
config
events
models
util}}
@@ -1649,7 +1649,6 @@
:uses #{analytics
api
app-db
config
database-routing
driver
events
@@ -2332,6 +2331,7 @@
premium-features
queries
query-processor
remote-sync
request
revisions
search

View File

@@ -99,12 +99,48 @@
:message "Metabase namespaces should have the form metabase[-enterprise].<module>.* [:metabase/namespace-name]"
:type :metabase/namespace-name)))))))
(defn- ns-form-node->import-node [ns-form-node]
(some (fn [node]
(when (and (hooks/list-node? node)
(let [first-child (first (:children node))]
(and (hooks/keyword-node? first-child)
(= (hooks/sexpr first-child) :import))))
node))
(:children ns-form-node)))
(defn- jsqlparser-import?
"Check if a symbol or string represents a jsqlparser import."
[s]
(str/starts-with? (str s) "net.sf.jsqlparser"))
(defn- lint-jsqlparser-imports [ns-form-node]
(when-let [import-node (ns-form-node->import-node ns-form-node)]
(doseq [node (rest (:children import-node))
:when (not (contains? (hooks.common/ignored-linters node) :metabase/no-jsqlparser-imports))]
(cond
;; Handle (net.sf.jsqlparser.foo Bar Baz) form
(hooks/list-node? node)
(let [package-node (first (:children node))
package-name (when package-node (str (hooks/sexpr package-node)))]
(when (jsqlparser-import? package-name)
(hooks/reg-finding! (assoc (meta node)
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
:type :metabase/no-jsqlparser-imports))))
;; Handle net.sf.jsqlparser.foo.Bar form (single class import)
(hooks/token-node? node)
(when (jsqlparser-import? (hooks/sexpr node))
(hooks/reg-finding! (assoc (meta node)
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
:type :metabase/no-jsqlparser-imports)))))))
(defn lint-ns [x]
(doto (:node x)
lint-require-shapes
lint-requires-on-new-lines
(lint-modules (modules/config x))
lint-namespace-name)
lint-namespace-name
lint-jsqlparser-imports)
x)
(comment

40
.github/scripts/find-linear-issue.js vendored Normal file
View File

@@ -0,0 +1,40 @@
async function findLinearIssue({ githubIssueUrl, linearApiKey }) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: linearApiKey,
},
body: JSON.stringify({
query: `
query {
attachmentsForURL(url: "${githubIssueUrl}") {
nodes {
issue { identifier }
}
}
}
`,
}),
});
const data = await response.json();
if (!response.ok) {
return { success: false, message: `Linear API error: ${response.status}` };
}
if (!data.data?.attachmentsForURL?.nodes?.length) {
return {
success: false,
message: `No Linear issue linked to ${githubIssueUrl}`,
};
}
return {
success: true,
issue: data.data.attachmentsForURL.nodes[0].issue.identifier,
};
}
module.exports = { findLinearIssue };

View File

@@ -19,11 +19,12 @@ while [[ $# -gt 0 ]]; do
done
echo "Cloning python-runner repository..."
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
git clone https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
if [ "$SKIP_START" = false ]; then
echo "Starting python-runner..."
cd python-runner
git checkout origin/memory_improvements_2
make run-ci 1> ../python-runner.out.log 2> ../python-runner.err.log
make logs 1>> ../python-runner.out.log 2>> ../python-runner.err.log &
cd ..

View File

@@ -70,6 +70,31 @@ jobs:
core.setFailed(error.message);
}
- name: Find linked Linear issue
id: find_linear
continue-on-error: true
uses: actions/github-script@v7
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
ISSUE_NUMBER: ${{ steps.get_issue.outputs.issue_number }}
with:
script: |
const { findLinearIssue } = require('${{ github.workspace }}/.github/scripts/find-linear-issue.js');
const githubIssueUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${process.env.ISSUE_NUMBER}`;
const result = await findLinearIssue({
githubIssueUrl,
linearApiKey: process.env.LINEAR_API_KEY
});
if (result.success) {
console.log(`Found Linear issue: ${result.issue}`);
core.setOutput('linear_issue', result.issue);
} else {
console.log(`Could not find Linear issue: ${result.message}`);
}
- name: Set up git configuration
run: |
git config --global user.name "Claude Code[bot]"
@@ -259,6 +284,7 @@ jobs:
core.setOutput('reviewers', issueCreator);
- name: Create Pull Request
id: create_pr
if: steps.check_changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v6
with:
@@ -279,6 +305,8 @@ jobs:
body: |
This PR automatically fixes the bug reported in #${{ env.ISSUE_NUMBER }}.
${{ steps.find_linear.outputs.linear_issue && format('Closes {0}', steps.find_linear.outputs.linear_issue) || '' }}
## Bug Report
**Original Issue:** #${{ env.ISSUE_NUMBER }}
**Reporter:** @${{ env.ISSUE_AUTHOR }}
@@ -299,7 +327,6 @@ jobs:
- If issues are found, either fix them or close this PR
---
Closes #${{ env.ISSUE_NUMBER }}
🤖 Generated with [Claude Code](https://claude.ai/code)
@@ -309,6 +336,7 @@ jobs:
automated-fix
ci skip
reviewers: ${{ steps.prepare_reviewers.outputs.reviewers }}
assignees: ${{ github.actor }}
- name: Log existing branch update
if: steps.check_changes.outputs.has_changes == 'true' && env.BRANCH_EXISTS == 'true'

View File

@@ -1,33 +0,0 @@
name: Cleanup caches by a branch
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-22.04
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
REPO=${{ github.repository }}
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -85,7 +85,6 @@ jobs:
be-tests-athena-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.athena-should-run == 'true' || needs.determine-driver-skips.outputs.athena-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -135,7 +134,6 @@ jobs:
be-tests-bigquery-cloud-sdk-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.bigquery-should-run == 'true' || needs.determine-driver-skips.outputs.bigquery-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -146,6 +144,7 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
# DS 2025-09-26 temporarily disabled while we diagnose flakes
# - name: Transforms Python Tests
# test-args: >-
@@ -206,7 +205,6 @@ jobs:
be-tests-clickhouse-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.clickhouse-should-run == 'true' || needs.determine-driver-skips.outputs.clickhouse-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -288,7 +286,6 @@ jobs:
be-tests-druid-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.druid-should-run == 'true' || needs.determine-driver-skips.outputs.druid-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -336,7 +333,6 @@ jobs:
be-tests-druid-jdbc-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.druid-jdbc-should-run == 'true' || needs.determine-driver-skips.outputs.druid-jdbc-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -384,7 +380,6 @@ jobs:
be-tests-databricks-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -432,7 +427,6 @@ jobs:
be-tests-databricks-multi-catalog-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -481,7 +475,6 @@ jobs:
be-tests-h2:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.h2-should-run == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -511,7 +504,6 @@ jobs:
be-tests-mysql-mariadb:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.mysql-mariadb-should-run == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -647,7 +639,6 @@ jobs:
be-tests-mongo:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.mongo-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -727,7 +718,6 @@ jobs:
be-tests-mongo-ssl:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.mongo-ssl-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-ssl-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -803,7 +793,7 @@ jobs:
be-tests-mongo-sharded-cluster-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-sharded-cluster-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -848,7 +838,6 @@ jobs:
be-tests-oracle:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.oracle-should-run == 'true' || needs.determine-driver-skips.outputs.oracle-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -933,7 +922,6 @@ jobs:
be-tests-postgres:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.postgres-should-run == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -1042,7 +1030,6 @@ jobs:
be-tests-presto-jdbc-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.presto-jdbc-should-run == 'true' || needs.determine-driver-skips.outputs.presto-jdbc-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -1123,7 +1110,6 @@ jobs:
be-tests-redshift-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.redshift-should-run == 'true' || needs.determine-driver-skips.outputs.redshift-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -1213,7 +1199,6 @@ jobs:
be-tests-snowflake-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.snowflake-should-run == 'true' || needs.determine-driver-skips.outputs.snowflake-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
@@ -1224,6 +1209,7 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
# DS 2025-09-26 temporarily disabled while we diagnose flakes
# - name: Transforms Python Tests
@@ -1294,7 +1280,6 @@ jobs:
be-tests-sparksql-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.sparksql-should-run == 'true' || needs.determine-driver-skips.outputs.sparksql-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -1340,7 +1325,6 @@ jobs:
be-tests-sqlite-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.sqlite-should-run == 'true' || needs.determine-driver-skips.outputs.sqlite-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -1381,7 +1365,6 @@ jobs:
be-tests-sqlserver:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.sqlserver-should-run == 'true' || needs.determine-driver-skips.outputs.sqlserver-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
@@ -1502,7 +1485,6 @@ jobs:
login-to-ecr:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.vertica-should-run == 'true' || needs.determine-driver-skips.outputs.vertica-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -1532,7 +1514,6 @@ jobs:
be-tests-vertica-ee:
needs: [determine-driver-skips, login-to-ecr]
if: ${{ needs.determine-driver-skips.outputs.vertica-should-run == 'true' || needs.determine-driver-skips.outputs.vertica-quarantine-conflict == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:

View File

@@ -120,6 +120,10 @@ jobs:
- name: Prepare Cypress environment
id: cypress-prep
uses: ./.github/actions/prepare-cypress
- name: Compile CLJS
run: yarn build-pure:cljs
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start

View File

@@ -104,6 +104,9 @@ jobs:
if: github.run_attempt != '1'
run: echo "specs=$(cat failed-specs)" >> $GITHUB_ENV
- name: Compile CLJS
run: yarn build-pure:cljs
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start

View File

@@ -19,6 +19,10 @@ For detailed guidance on writing and reviewing code and documentation, see the s
- **[docs-write](.claude/skills/docs-write/SKILL.md)** - Documentation writing with Metabase style guide
- **[docs-review](.claude/skills/docs-review/SKILL.md)** - Documentation review checklist
## Frontend
- **[analytics-events](.claude/skills/analytics-events/SKILL.md)** - Add product analytics events to track user interactions
## Tool Preferences
If `clojure-mcp` tools are available, prefer them over shell-based alternatives for Clojure development.

46
bb.edn
View File

@@ -307,32 +307,38 @@
:examples [["./bin/mage -driver-decisions --git-ref=master --is-master-or-release=false --pr-labels= --skip=false" "Determine driver decisions for a PR"]
["./bin/mage -driver-decisions --pr-labels=ci:all-cloud-drivers" "Run all cloud drivers via label"]
["./bin/mage -driver-decisions --github-output-only" "Output only GITHUB_OUTPUT format (for CI)"]]
:requires [[clojure.string :as str]
[table.core :as table]
[mage.modules]]
:usage-fn
(fn [_]
(->>
["# How we decide to run a driver's tests. For each driver, we check these conditions in order (first match wins):"
" |----------|-------------------------------------|--------|-------------------------------------------|"
" | Priority | Condition | Result | Reason |"
" |----------|-------------------------------------|--------|-------------------------------------------|"
" | 1 | Global skip (no backend changes) | SKIP | workflow skip (no backend changes) |"
" | 2 | Driver is :h2 | RUN | H2 always runs |"
" | 3 | Driver is quarantined | SKIP | driver is quarantined |"
" | 3(a) | ...and has break-quarantine-X label | RUN | anti-quarantine label present |"
" | 4 | On master or release branch | RUN | master/release branch |"
" | 5 | Cloud driver + ci:all-cloud-drivers | RUN | ci:all-cloud-drivers label |"
" | 6 | Cloud driver + its files changed | RUN | driver files changed |"
" | 7 | Cloud driver, no relevant changes | SKIP | no relevant changes for cloud driver |"
" | 8 | Driver deps affected by changes | RUN | driver module affected by shared code |"
" | 9 | Self-hosted driver, not affected | SKIP | driver module not affected |"
" |----------|-------------------------------------|--------|-------------------------------------------|"
""] (str/join "\n")))
(fn [_current-task]
(let [driver-triggers (vec (sort mage.modules/default-modules-which-trigger-drivers))]
(str
"# How we decide to run a driver's tests. For each driver, we check these conditions in order (first match wins):\n"
(with-out-str
(table/table [["Priority" "Condition" "Result" "Reason"]
["1" "Global skip (no backend changes)" "SKIP" "workflow skip (no backend changes)"]
["2" "Driver is :h2 or :postgres" "RUN" "H2/Postgres always run"]
["3" "Driver is quarantined" "SKIP" "driver is quarantined"]
["3(a)" "...and has break-quarantine-X label" "RUN" "anti-quarantine label present"]
["4" "On master or release branch" "RUN" "master/release branch"]
["5" "Cloud driver + ci:all-cloud-drivers" "RUN" "ci:all-cloud-drivers label"]
["6"
"Cloud driver + its files changed"
"RUN"
(str "any of " (pr-str driver-triggers) " affected by cloud-driver module changes")]
["7" "Cloud driver, no relevant changes" "SKIP" "no relevant changes for cloud driver"]
["8"
(str (pr-str driver-triggers) " module deps affected")
"RUN"
(str "any of " (pr-str driver-triggers) " affected by module changes")]
["9" "Self-hosted driver, not affected" "SKIP" "driver module not affected"]])))))
:options [[nil "--git-ref REF" "The git ref to compute changed modules against"]
[nil "--is-master-or-release MASTER_OR_RELEASE"]
[nil "--pr-labels PR_LABELS" "Comma delimited labels on the pr"]
[nil "--skip SKIP"]
[nil "--github-output-only" "Only output GITHUB_OUTPUT format (no human-readable output)"]]
:requires [[clojure.string :as str]
[mage.modules]]
:task (task! (mage.modules/-main parsed))}
-install-merge-drivers

View File

@@ -80,11 +80,11 @@
hiccup/hiccup {:mvn/version "2.0.0"} ; HTML templating
inflections/inflections {:mvn/version "0.15.0"} ; Clojure/Script library used for prularizing words
instaparse/instaparse {:mvn/version "1.5.0"} ; Make your own parser
io.github.camsaul/toucan2 {:mvn/version "1.0.569"}
io.github.camsaul/toucan2 {:mvn/version "1.0.570"}
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
:git/url "https://github.com/eerohele/pp"}
io.github.metabase/macaw {:mvn/version "0.2.30"} ; Parse native SQL queries
io.github.metabase/macaw {:mvn/version "0.2.33"} ; Parse native SQL queries
io.netty/netty-codec-http {:mvn/version "4.2.9.Final"} ; override of transitive dep from aws s3
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
junegunn/grouper {:mvn/version "0.1.1"} ; Batch processing helper
@@ -362,6 +362,8 @@
"-Dmb.dangerous.unsafe.enable.testing.h2.connections.do.not.enable=true"
;; Ignore connection URI in favor of the MB_DB_FILE
"-Dmb.db.connection.uri="
;; Unset the premium token so that tests can set it programmatically
"-Dmb.premium.embedding.token="
;; Ignore config.yml
"-Dmb.config.file.path=__cypress__"
;; We use Snowplow micro in e2e tests

View File

@@ -306,6 +306,7 @@
{:current-user-id api/*current-user-id*
:current-user-perms @api/*current-user-permissions-set*
:is-superuser? api/*is-superuser?*
:is-data-analyst api/*is-data-analyst?*
:is-impersonated-user? (perms-util/impersonated-user?)
:is-sandboxed-user? (perms-util/impersonated-user?)
:archived false

View File

@@ -1273,10 +1273,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1434,10 +1434,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1476,10 +1476,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1515,10 +1515,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1608,10 +1608,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1642,10 +1642,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1692,10 +1692,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1731,10 +1731,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1760,7 +1760,8 @@
"display_name" : { },
"fields" : { },
"name" : { },
"schema" : { }
"schema" : { },
"transform" : { }
}
},
"dependents_count" : {
@@ -1770,10 +1771,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1843,10 +1844,10 @@
"type" : "null"
} ]
},
"errors" : {
"dependents_errors" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
},
"uniqueItems" : true
},
@@ -1874,6 +1875,52 @@
"type" : "integer",
"minimum" : 1
},
"metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error" : {
"description" : "Schema for an AnalysisFindingError record.",
"type" : "object",
"properties" : {
"analyzed_entity_id" : {
"description" : "value must be an integer greater than zero.",
"type" : "integer",
"minimum" : 1
},
"analyzed_entity_type" : {
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.dependency-types.dependency-types"
},
"error_detail" : {
"oneOf" : [ {
"type" : "string"
}, {
"type" : "null"
} ]
},
"error_type" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.validate-error-type"
},
"id" : {
"description" : "value must be an integer greater than zero.",
"type" : "integer",
"minimum" : 1
},
"source_entity_id" : {
"oneOf" : [ {
"description" : "value must be an integer greater than zero.",
"type" : "integer",
"minimum" : 1
}, {
"type" : "null"
} ]
},
"source_entity_type" : {
"oneOf" : [ {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.source-entity-type"
}, {
"type" : "null"
} ]
}
},
"required" : [ "id", "analyzed_entity_type", "analyzed_entity_id", "error_type" ]
},
"metabase-enterprise.metabot-v3.client.schema.message" : {
"type" : "object",
"properties" : {
@@ -8536,6 +8583,7 @@
}
}
},
"metabase.lib.schema.deduplicate-refs-ignoring-source-field-name-when-possible" : { },
"metabase.lib.schema.expression..expression.definition" : {
"$ref" : "#/components/schemas/metabase.lib.schema.expression.expression"
},
@@ -8720,6 +8768,8 @@
"minItems" : 1
}, {
"$ref" : "#/components/schemas/metabase.lib.schema.util.distinct-mbql-clauses"
}, {
"$ref" : "#/components/schemas/metabase.lib.schema.deduplicate-refs-ignoring-source-field-name-when-possible"
} ]
},
"metabase.lib.schema.filter.string-filter-options" : {
@@ -11179,88 +11229,14 @@
},
"metabase.lib.schema.util.distinct-mbql-clauses" : { },
"metabase.lib.schema.util.unique-uuids" : { },
"metabase.lib.schema.validate.duplicate-column-error" : {
"type" : "object",
"properties" : {
"name" : {
"type" : "string"
},
"type" : {
"const" : "validate/duplicate-column"
}
},
"required" : [ "type", "name" ]
},
"metabase.lib.schema.validate.error" : {
"allOf" : [ {
"type" : "object",
"properties" : {
"type" : {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.validate-error-type"
}
},
"required" : [ "type" ]
}, {
"oneOf" : [ {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.missing-column-error"
}, {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.missing-table-alias-error"
}, {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.duplicate-column-error"
}, {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.syntax-error"
}, {
"$ref" : "#/components/schemas/metabase.lib.schema.validate.validation-exception-error"
} ]
} ]
},
"metabase.lib.schema.validate.missing-column-error" : {
"type" : "object",
"properties" : {
"name" : {
"type" : "string"
},
"type" : {
"const" : "validate/missing-column"
}
},
"required" : [ "type", "name" ]
},
"metabase.lib.schema.validate.missing-table-alias-error" : {
"type" : "object",
"properties" : {
"name" : {
"type" : "string"
},
"type" : {
"const" : "validate/missing-table-alias"
}
},
"required" : [ "type", "name" ]
},
"metabase.lib.schema.validate.syntax-error" : {
"type" : "object",
"properties" : {
"type" : {
"const" : "validate/syntax-error"
}
},
"required" : [ "type" ]
"metabase.lib.schema.validate.source-entity-type" : {
"description" : "The type of the source entity causing an error.",
"type" : "string",
"enum" : [ "table", "card" ]
},
"metabase.lib.schema.validate.validate-error-type" : {
"type" : "string"
},
"metabase.lib.schema.validate.validation-exception-error" : {
"type" : "object",
"properties" : {
"message" : {
"type" : "string"
},
"type" : {
"const" : "validate/validation-exception-error"
}
},
"required" : [ "type", "message" ]
"type" : "string",
"enum" : [ "missing-column", "missing-table-alias", "duplicate-column", "syntax-error", "validation-exception-error" ]
},
"metabase.logger.api.log-level" : {
"type" : "string",
@@ -12585,6 +12561,10 @@
"type" : "string",
"enum" : [ "alert", "sync", "fingerprint", "subscription" ]
},
"started-at" : {
"type" : "string",
"minLength" : 1
},
"status" : {
"type" : "string",
"enum" : [ "started", "success", "failed", "abandoned" ]
@@ -22689,7 +22669,7 @@
"/api/ee/dependencies/graph/broken" : {
"get" : {
"summary" : "GET /api/ee/dependencies/graph/broken",
"description" : "Returns a list of all items with broken queries.\n\n Accepts optional parameters for filtering:\n - `types`: List of entity types to include (e.g., `[:card :transform :snippet :dashboard]`)\n - `card_types`: List of card types to include when filtering cards (e.g., `[:question :model :metric]`)\n - `query`: Search string to filter by name or location\n - `archived`: Controls whether archived entities are included\n - `include_personal_collections`: Controls whether items in personal collections are included (default: false)\n - `sort_column`: Sort column - `:name`, `:location`, or `:dependents-count` (default: `:name`)\n - `sort_direction`: Sort direction - `:asc` or `:desc` (default: `:asc`)\n - `offset`: Default 0\n - `limit`: Default 50\n\n Returns a map with:\n - `data`: List of broken items, each with `:id`, `:type`, `:data`, and `:error`s fields\n - `total`: Total count of matched items\n - `offset`: Applied offset\n - `limit`: Applied limit",
"description" : "Returns a list of entities that are causing errors in downstream dependents\n These are tables or cards that other entities depend on, where those dependents\n have validation errors traced back to this source entity.\n\n Accepts optional parameters for filtering:\n - `types`: List of source entity types - only `:card` or `:table` (default: both)\n - `card_types`: List of card types to include when filtering cards (e.g., `[:question :model :metric]`)\n - `query`: Search string to filter by name or location\n - `archived`: Controls whether archived entities are included\n - `include_personal_collections`: Controls whether items in personal collections are included (default: false)\n - `sort_column`: Sort column - `:name`, `:location`, `:dependents-errors`, or `:dependents-with-errors` (default: `:name`)\n - `sort_direction`: Sort direction - `:asc` or `:desc` (default: `:asc`)\n - `offset`: Default 0\n - `limit`: Default 50\n\n Returns a map with:\n - `data`: List of breaking source entities\n - `total`: Total count of matched items\n - `offset`: Applied offset\n - `limit`: Applied limit",
"parameters" : [ {
"in" : "query",
"name" : "types",
@@ -22747,7 +22727,7 @@
"required" : false,
"schema" : {
"type" : "string",
"enum" : [ "name", "location", "dependents-count" ]
"enum" : [ "dependents-with-errors", "dependents-errors", "name", "location" ]
}
}, {
"in" : "query",
@@ -22804,7 +22784,7 @@
"/api/ee/dependencies/graph/dependents" : {
"get" : {
"summary" : "GET /api/ee/dependencies/graph/dependents",
"description" : "This endpoint takes an :id, :type, :dependent_type, and an optional :dependent_card_type, and returns a list of\n all that entity's dependents with :dependent_type. If the :dependent_type is :card, the dependents are further\n filtered by :dependent_card_type.\n\n Optional :archived parameter controls whether entities in archived collections are included:\n - false (default): Excludes entities in archived collections\n - true: Includes entities in archived collections",
"description" : "Returns a list of dependents for the specified entity.\n\n Required parameters:\n - `id`: The ID of the entity\n - `type`: The type of the entity (card, table, dashboard, etc.)\n\n Optional parameters:\n - `dependent_types`: Dependency types to filter by. Can be single value or array.\n If not provided, returns all types. Example: ?dependent_types=card&dependent_types=dashboard\n - `dependent_card_types`: Card types to filter by when dependent_types includes :card.\n Ignored if dependent_types doesn't include :card. Example: ?dependent_card_types=question&dependent_card_types=model\n - `archived`: Include entities in archived collections (default: false)\n - `broken`: Return only broken entities (default: false)\n - `query`: Search string to filter results by name or location (case-insensitive)\n - `include_personal_collections`: Include items in personal collections (default: false)\n - `sort_column`: Column to sort by - name, location, or view-count (default: name)\n - `sort_direction`: Sort direction - asc or desc (default: asc)",
"parameters" : [ {
"in" : "query",
"name" : "id",
@@ -22823,18 +22803,33 @@
}
}, {
"in" : "query",
"name" : "dependent_type",
"required" : true,
"name" : "dependent_types",
"required" : false,
"schema" : {
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.dependency-types.dependency-types"
"anyOf" : [ {
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.dependency-types.dependency-types"
}, {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.dependency-types.dependency-types"
}
} ]
}
}, {
"in" : "query",
"name" : "dependent_card_type",
"name" : "dependent_card_types",
"required" : false,
"schema" : {
"type" : "string",
"enum" : [ "question", "metric", "model" ]
"anyOf" : [ {
"type" : "string",
"enum" : [ "question", "metric", "model" ]
}, {
"type" : "array",
"items" : {
"type" : "string",
"enum" : [ "question", "metric", "model" ]
}
} ]
}
}, {
"in" : "query",
@@ -22843,6 +22838,43 @@
"schema" : {
"type" : "boolean"
}
}, {
"in" : "query",
"name" : "broken",
"required" : false,
"schema" : {
"type" : "boolean"
}
}, {
"in" : "query",
"name" : "query",
"required" : false,
"schema" : {
"type" : "string"
}
}, {
"in" : "query",
"name" : "include_personal_collections",
"required" : false,
"schema" : {
"type" : "boolean"
}
}, {
"in" : "query",
"name" : "sort_column",
"required" : false,
"schema" : {
"type" : "string",
"enum" : [ "name", "view-count", "location" ]
}
}, {
"in" : "query",
"name" : "sort_direction",
"required" : false,
"schema" : {
"type" : "string",
"enum" : [ "desc", "asc" ]
}
} ],
"responses" : {
"2XX" : {
@@ -22871,7 +22903,7 @@
"/api/ee/dependencies/graph/unreferenced" : {
"get" : {
"summary" : "GET /api/ee/dependencies/graph/unreferenced",
"description" : "Returns a list of all unreferenced items in the instance.\n An unreferenced item is one that is not a dependency of any other item.\n\n Accepts optional parameters for filtering:\n - `types`: List of entity types to include (e.g., [:card :transform :snippet :dashboard])\n - `card_types`: List of card types to include when filtering cards (e.g., [:question :model :metric])\n - `query`: Search string to filter by name or location\n - `archived`: Controls whether archived entities are included\n - `include_personal_collections`: Controls whether items in personal collections are included (default: false)\n - `sort_column`: Sort column - `:name`, `:location`, or `:dependents-count` (default: `:name`)\n - `sort_direction`: Sort direction - `:asc` or `:desc` (default: `:asc`)\n - `offset`: Default 0\n - `limit`: Default 50\n\n Returns a map with:\n - `data`: List of unreferenced items, each with `:id`, `:type`, and `:data` fields\n - `total`: Total count of matched items\n - `offset`: Applied offset\n - `limit`: Applied limit",
"description" : "Returns a list of all unreferenced items in the instance.\n An unreferenced item is one that is not a dependency of any other item.\n\n Accepts optional parameters for filtering:\n - `types`: List of entity types to include (e.g., [:card :transform :snippet :dashboard])\n - `card_types`: List of card types to include when filtering cards (e.g., [:question :model :metric])\n - `query`: Search string to filter by name or location\n - `archived`: Controls whether archived entities are included\n - `include_personal_collections`: Controls whether items in personal collections are included (default: false)\n - `sort_column`: Sort column - `:name`, `:location`, `:dependents-errors`, or `:dependents-with-errors` (default: `:name`)\n - `sort_direction`: Sort direction - `:asc` or `:desc` (default: `:asc`)\n - `offset`: Default 0\n - `limit`: Default 50\n\n Returns a map with:\n - `data`: List of unreferenced items, each with `:id`, `:type`, and `:data` fields\n - `total`: Total count of matched items\n - `offset`: Applied offset\n - `limit`: Applied limit",
"parameters" : [ {
"in" : "query",
"name" : "types",
@@ -22929,7 +22961,7 @@
"required" : false,
"schema" : {
"type" : "string",
"enum" : [ "name", "location", "dependents-count" ]
"enum" : [ "dependents-with-errors", "dependents-errors", "name", "location" ]
}
}, {
"in" : "query",
@@ -23225,7 +23257,7 @@
"/api/ee/library/" : {
"post" : {
"summary" : "POST /api/ee/library/",
"description" : "Creates the Library if it doesn't exist. Returns the created collection.\n\n Requires superuser permissions.",
"description" : "Creates the Library if it doesn't exist. Returns the created collection.\n\n Requires data analyst or superuser permissions.",
"parameters" : [ ],
"responses" : {
"2XX" : {
@@ -26065,6 +26097,13 @@
"type" : "null"
} ]
},
"remote-sync-auto-import" : {
"oneOf" : [ {
"type" : "boolean"
}, {
"type" : "null"
} ]
},
"remote-sync-branch" : {
"oneOf" : [ {
"type" : "string"
@@ -26079,6 +26118,13 @@
"type" : "null"
} ]
},
"remote-sync-transforms" : {
"oneOf" : [ {
"type" : "boolean"
}, {
"type" : "null"
} ]
},
"remote-sync-type" : {
"oneOf" : [ {
"type" : "string",
@@ -28168,31 +28214,11 @@
"/api/ee/transform/extract-columns" : {
"post" : {
"summary" : "POST /api/ee/transform/extract-columns",
"description" : "Extract column names suitable for incremental transform checkpoint filtering.\n\n This endpoint is specifically for populating the checkpoint column dropdown in\n incremental transforms. It only returns columns with types supported for checkpoint\n filtering: temporal (timestamp/tz) and numeric (int/float) types.\n\n Text, boolean, and other unsupported column types are filtered out.\n\n The query is compiled to native SQL using [[qp.compile/compile-with-inline-parameters]],\n which handles parameterized queries with template tags. Then extracts column names\n and types using PreparedStatement metadata.\n\n Returns a map with a :columns key containing a vector of column names (strings).\n If extraction fails, returns nil for :columns.",
"description" : "Extract column names from an MBQL query without executing it.\n\n Compiles the query to native SQL using [[qp.compile/compile-with-inline-parameters]],\n which handles parameterized queries with template tags. Then extracts column names\n using PreparedStatement metadata.\n\n Returns a map with a :columns key containing a vector of column names (strings).\n If extraction fails, returns nil for :columns.",
"parameters" : [ ],
"responses" : {
"2XX" : {
"description" : "Successful response",
"content" : {
"application/json" : {
"schema" : {
"type" : "object",
"properties" : {
"columns" : {
"oneOf" : [ {
"type" : "array",
"items" : {
"type" : "string"
}
}, {
"type" : "null"
} ]
}
},
"required" : [ "columns" ]
}
}
}
"description" : "Successful response"
},
"4XX" : {
"description" : "Client error response"
@@ -37446,6 +37472,14 @@
"type" : "string",
"enum" : [ "alert", "sync", "fingerprint", "subscription" ]
}
}, {
"in" : "query",
"name" : "started-at",
"required" : true,
"schema" : {
"type" : "string",
"minLength" : 1
}
} ],
"responses" : {
"2XX" : {
@@ -38655,6 +38689,28 @@
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "is_data_analyst",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "boolean"
}, {
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "can_access_data_studio",
"required" : false,
"schema" : {
"oneOf" : [ {
"type" : "boolean"
}, {
"type" : "null"
} ]
}
}, {
"in" : "query",
"name" : "tenancy",
@@ -38901,6 +38957,13 @@
"type" : "null"
} ]
},
"is_data_analyst" : {
"oneOf" : [ {
"type" : "boolean"
}, {
"type" : "null"
} ]
},
"locale" : {
"oneOf" : [ {
"description" : "String must be a valid two-letter ISO language or language-country code e.g. 'en' or 'en_US'.",

View File

@@ -224,6 +224,7 @@ config:
remote-sync-auto-import-rate: 5
remote-sync-check-changes-cache-ttl-seconds: 60
remote-sync-task-time-limit-ms: 300000
remote-sync-transforms: false
report-timezone: null
reset-token-ttl-hours: 48
retry-initial-interval: 500
@@ -271,6 +272,12 @@ config:
site-url: null
slack-app-token: null
slack-bug-report-channel: metabase-bugs
slack-connect-attribute-team-id: https://slack.com/team_id
slack-connect-authentication-mode: sso
slack-connect-client-id: null
slack-connect-client-secret: null
slack-connect-enabled: false
slack-connect-user-provisioning-enabled: true
smtp-override-enabled: false
source-address-header: X-Forwarded-For
sql-jdbc-fetch-size: 500

View File

@@ -1385,6 +1385,14 @@ Time-to-live in seconds for the remote changes check cache. Default is 60 second
The maximum amount of time a remote sync task will be given to complete.
### `MB_REMOTE_SYNC_TRANSFORMS`
- Type: boolean
- Default: `false`
- [Configuration file name](./config-file.md): `remote-sync-transforms`
Whether to sync transforms via remote-sync. When enabled, all transforms, transform tags, and transform jobs are synced as a single unit (all-or-nothing).
### `MB_REPORT_TIMEZONE`
- Type: string
@@ -1831,6 +1839,66 @@ Bot user OAuth token for connecting the Metabase Slack app. This should be used
The name of the channel where bug reports should be posted.
### `MB_SLACK_CONNECT_ATTRIBUTE_TEAM_ID`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: string
- Default: `https://slack.com/team_id`
- [Configuration file name](./config-file.md): `slack-connect-attribute-team-id`
Slack OIDC claim for the team/workspace ID.
### `MB_SLACK_CONNECT_AUTHENTICATION_MODE`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: string
- Default: `sso`
- [Configuration file name](./config-file.md): `slack-connect-authentication-mode`
Controls whether Slack can be used for SSO login or just account linking. Valid values: "sso" (default) or "link-only".
### `MB_SLACK_CONNECT_CLIENT_ID`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: string
- Default: `null`
- [Configuration file name](./config-file.md): `slack-connect-client-id`
Client ID for your Slack app. Get this from https://api.slack.com/apps.
### `MB_SLACK_CONNECT_CLIENT_SECRET`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: string
- Default: `null`
- [Configuration file name](./config-file.md): `slack-connect-client-secret`
Client Secret for your Slack app.
### `MB_SLACK_CONNECT_ENABLED`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: boolean
- Default: `false`
- [Configuration file name](./config-file.md): `slack-connect-enabled`
Is Slack Connect authentication configured and enabled?
### `MB_SLACK_CONNECT_USER_PROVISIONING_ENABLED`
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
- Type: boolean
- Default: `true`
- [Configuration file name](./config-file.md): `slack-connect-user-provisioning-enabled`
When a user logs in via Slack Connect, create a Metabase account for them automatically if they don't have one.
### `MB_SLACK_FILES_CHANNEL [DEPRECATED]`
> DEPRECATED: 0.54.0

View File

@@ -15,8 +15,12 @@ To restrict data in guest embeds for specific people or groups, use [locked para
## Turning on guest embedding in Metabase
1. Go to **Settings > Admin settings > Embedding > Guest embeds**.
2. Toggle **Enable guest embedding**.
The path to embedding settings depends on your Metabase version:
- **OSS**: **Admin settings > Embedding**
- **Starter/Pro/Enterprise**: **Admin settings > Embedding > Guest embeds**
Toggle **Enable guest embeds**.
## Creating a guest embed
@@ -240,14 +244,14 @@ The end user won't see the "category" filter, but the dashboard will only show d
## Disabling embedding for a question or dashboard
You can find a list of all guest embeds of questions and dashboards from **Admin settings** > **Embedding** > **Guest embeds**.
1. Visit the embeddable question or dashboard.
2. Click the **sharing icon** (square with an arrow pointing to the top right).
3. Select **Embed**.
4. Select **Guest embedding**
5. Click **Unpublish**.
Admins can find a list of embedded items in **Admin settings > Embedding** (on Pro and Enterprise plans, check the **Guest embeds** tab).
## Removing the "Powered by Metabase" banner
![Powered by Metabase](./images/powered-by-metabase.png)
@@ -258,7 +262,7 @@ The banner appears on guest embeds created with Metabase's open-source version.
Your embedding secret key is used to sign JWTs for all of your embeds.
1. Go to **Admin** > **Embedding** > **Guest embeds**.
1. Go to **Admin settings > Embedding**. On Pro and Enterprise plans, check the **Guest embeds** tab.
2. Under **Regenerate secret key**, click **Regenerate key**.
This key is shared across all guest embeds. Whoever has access to this key could get access to all embedded artifacts, so keep this key secure. If you regenerate this key, you'll need to update your server code with the new key.

View File

@@ -17,6 +17,9 @@ const options = {
GENERATE_SNAPSHOTS: true,
JAR_PATH: undefined,
...booleanify(process.env),
// If this token is present in your env when Cypress runs, it's a mistake.
// Cypress needs to start from a clean slate in order to set the token programmatically.
MB_PREMIUM_EMBEDDING_TOKEN: undefined,
};
process.env = unBooleanify(options);

View File

@@ -125,22 +125,22 @@ describe("snapshots", () => {
// groups
cy.request("POST", "/api/permissions/group", { name: "collection" }).then(
({ body }) => {
expect(body.id).to.eq(COLLECTION_GROUP); // 4
expect(body.id).to.eq(COLLECTION_GROUP); // 5
},
);
cy.request("POST", "/api/permissions/group", { name: "data" }).then(
({ body }) => {
expect(body.id).to.eq(DATA_GROUP); // 5
expect(body.id).to.eq(DATA_GROUP); // 6
},
);
cy.request("POST", "/api/permissions/group", { name: "readonly" }).then(
({ body }) => {
expect(body.id).to.eq(READONLY_GROUP); // 6
expect(body.id).to.eq(READONLY_GROUP); // 7
},
);
cy.request("POST", "/api/permissions/group", { name: "nosql" }).then(
({ body }) => {
expect(body.id).to.eq(NOSQL_GROUP); // 7
expect(body.id).to.eq(NOSQL_GROUP); // 8
},
);

View File

@@ -46,6 +46,67 @@ const assetsResolverPlugin = {
},
};
// Plugin to allow loading the ClojureScript bundle only when it exists
// on the filesystem. This is to avoid breaking the build when the bundle
// is not present.
//
// If the bundle is not present, tests relying on it will fail, but the Cypress
// process will be allowed to start.
//
// The error will look like this:
//
// Dynamic require of "cljs/metabase.types.core" is not supported
//
// This allows us to avoid building the ClojureScript bundle when we don't need to (like in certain CI tasks).
const virtualClojureScriptPlugin = {
name: "virtualClojureScriptBundle",
setup(build) {
let cljsDir = null;
async function isDirectory(path) {
try {
const stat = await fs.promises.stat(path);
return stat.isDirectory();
} catch (e) {
return false;
}
}
build.onStart(async () => {
const baseDir = path.join(__dirname, "../../target");
const devDir = path.join(baseDir, "cljs_dev");
const prodDir = path.join(baseDir, "cljs_prod");
const [devDirExists, prodDirExists] = await Promise.all([
isDirectory(devDir),
isDirectory(prodDir),
]);
if (prodDirExists) {
console.log("ClojureScript production bundle found");
cljsDir = prodDir;
} else if (devDirExists) {
console.log("ClojureScript development bundle found");
cljsDir = devDir;
} else {
console.log("No ClojureScript bundle found, not loading bundle");
cljsDir = null;
}
});
build.onResolve({ filter: /^cljs\// }, async (args) => {
if (cljsDir === null) {
return {
path: args.path,
external: true,
};
}
const unnested = args.path.split(path.sep).slice(1).join(path.sep);
return { path: path.join(cljsDir, unnested + ".js") };
});
},
};
const defaultConfig = {
// This is the functionality of the old cypress-plugins.js file
setupNodeEvents(cypressOn, config) {
@@ -75,7 +136,11 @@ const defaultConfig = {
loader: {
".svg": "text",
},
plugins: [NodeModulesPolyfillPlugin(), assetsResolverPlugin],
plugins: [
NodeModulesPolyfillPlugin(),
assetsResolverPlugin,
virtualClojureScriptPlugin,
],
sourcemap: "inline",
}),
);

View File

@@ -43,14 +43,15 @@ export const SAMPLE_DB_TABLES = {
export const USER_GROUPS = {
ALL_USERS_GROUP: 1,
ADMIN_GROUP: 2,
COLLECTION_GROUP: 4,
DATA_GROUP: 5,
READONLY_GROUP: 6,
NOSQL_GROUP: 7,
COLLECTION_GROUP: 5,
DATA_GROUP: 6,
READONLY_GROUP: 7,
NOSQL_GROUP: 8,
};
export const TENANT_USER_GROUPS = {
export const MAGIC_USER_GROUPS = {
EXTERNAL_USERS_GROUP: 3,
DATA_ANALYSTS_GROUP: 4,
};
const {

View File

@@ -156,6 +156,10 @@ export const ALL_EXTERNAL_USERS_GROUP_ID = _.findWhere(
},
)?.id;
export const DATA_ANALYSTS_GROUP_ID = _.findWhere(SAMPLE_INSTANCE_DATA.groups, {
magic_group_type: "data-analyst",
})?.id;
export const COLLECTION_GROUP_ID = _.findWhere(SAMPLE_INSTANCE_DATA.groups, {
name: "collection",
}).id;

View File

@@ -38,6 +38,7 @@ export const DataStudio = {
},
PythonLibrary: {
header: () => cy.findByTestId("python-library-header"),
editor: () => cy.findByTestId("python-editor"),
},
Snippets: {
newPage: newSnippetPage,
@@ -131,26 +132,4 @@ export const DataStudio = {
collectionItem: (name: string) =>
libraryPage().findAllByTestId("collection-name").contains(name),
},
Tasks: {
visitBrokenEntities: () => cy.visit("/data-studio/tasks/broken"),
visitUnreferencedEntities: () =>
cy.visit("/data-studio/tasks/unreferenced"),
list: () => cy.findByTestId("dependency-list"),
searchInput: () => cy.findByTestId("dependency-list-search-input"),
filterButton: () => cy.findByTestId("dependency-list-filter-button"),
sidebar: () => cy.findByTestId("dependency-list-sidebar"),
Sidebar: {
get: () => cy.findByTestId("dependency-list-sidebar"),
header: () => cy.findByTestId("dependency-list-sidebar-header"),
locationInfo: () => cy.findByRole("region", { name: "Location" }),
transformInfo: () => cy.findByRole("region", { name: "Transform" }),
missingColumnsInfo: () =>
cy.findByRole("region", { name: "Missing columns" }),
creationInfo: () =>
cy.findByRole("region", { name: "Creator and last editor" }),
brokenDependentsInfo: () =>
cy.findByRole("region", { name: "Broken dependents" }),
},
},
};

View File

@@ -1,7 +0,0 @@
export const DependencyGraph = {
graph: () => cy.findByTestId("dependency-graph"),
entryButton: () => cy.findByTestId("graph-entry-button"),
entrySearchInput: () => cy.findByTestId("graph-entry-search-input"),
selectionButton: () => cy.findByTestId("graph-selection-button"),
dependencyPanel: () => cy.findByTestId("graph-dependency-panel"),
};

View File

@@ -0,0 +1,31 @@
export const DependencyGraph = {
graph: () => cy.findByTestId("dependency-graph"),
entryButton: () => cy.findByTestId("graph-entry-button"),
entrySearchInput: () => cy.findByTestId("graph-entry-search-input"),
selectionButton: () => cy.findByTestId("graph-selection-button"),
dependencyPanel: () => cy.findByTestId("graph-dependency-panel"),
};
export const DependencyDiagnostics = {
visitBrokenDependencies: () =>
cy.visit("/data-studio/dependency-diagnostics/broken"),
visitUnreferencedEntities: () =>
cy.visit("/data-studio/dependency-diagnostics/unreferenced"),
list: () => cy.findByTestId("dependency-list"),
searchInput: () => cy.findByTestId("dependency-list-search-input"),
filterButton: () => cy.findByTestId("dependency-filter-button"),
sidebar: () => cy.findByTestId("dependency-list-sidebar"),
Sidebar: {
get: () => cy.findByTestId("dependency-list-sidebar"),
header: () => cy.findByTestId("dependency-list-sidebar-header"),
locationSection: () => cy.findByRole("region", { name: "Location" }),
transformSection: () => cy.findByRole("region", { name: "Transform" }),
missingColumnsSection: () =>
cy.findByRole("region", { name: "Missing columns" }),
creationSection: () =>
cy.findByRole("region", { name: "Creator and last editor" }),
brokenDependentsSection: () =>
cy.findByRole("region", { name: "Broken dependents" }),
},
};

View File

@@ -3,6 +3,11 @@ interface User {
first_name?: string | null;
last_name?: string | null;
}
export function setUserAsAnalyst(userId: number) {
cy.request("PUT", `/api/user/${userId}`, { is_data_analyst: true });
}
/**
* Get user's full name, or an email address if name is not available.
*/

View File

@@ -1,4 +1,5 @@
export * from "./api";
export * from "./mbql";
export * from "./e2e-action-helpers";
export * from "./e2e-ad-hoc-question-helpers";
export * from "./e2e-api-key-helpers";
@@ -16,7 +17,7 @@ export * from "./e2e-dashboard-visualizer-helpers";
export * from "./e2e-database-metadata-helpers";
export * from "./e2e-datamodel-helpers";
export * from "./e2e-data-studio-helpers";
export * from "./e2e-dependency-graph-helpers";
export * from "./e2e-dependency-helpers";
export * from "./e2e-detail-view-helpers";
export * from "./e2e-dimension-list-helpers";
export * from "./e2e-document-helpers";

View File

@@ -0,0 +1,6 @@
export {
getMetadataProvider,
createQuery,
createNativeQuery,
} from "./wrappers";
export { createCardWithQuery, createCardWithNativeQuery } from "./question";

View File

@@ -0,0 +1,86 @@
// WARNING:
// This module should not be imported directly in cypress
// harnesses, use an dynamic import instead.
import { getMetadataWithoutSettings as getMetadataFromState } from "metabase/selectors/metadata";
import * as Lib from "metabase-lib";
import type {
Card,
CardId,
Database,
Table,
TableId,
} from "metabase-types/api";
import { createMockEntitiesState } from "../../../../frontend/test/__support__/store";
import { SAMPLE_DB_ID, SAMPLE_DB_TABLES } from "../../cypress_data";
import type { GetMetadataOpts } from "./types";
const DEFAULT_TABLE_IDS: TableId[] = [
SAMPLE_DB_TABLES.STATIC_ORDERS_ID,
SAMPLE_DB_TABLES.STATIC_PEOPLE_ID,
SAMPLE_DB_TABLES.STATIC_PRODUCTS_ID,
SAMPLE_DB_TABLES.STATIC_REVIEWS_ID,
];
const DEFAULT_CARD_IDS: CardId[] = [];
/**
* Returns a Lib.MetadataProvider instance containing metadata for the
* provided database, tables and cards.
*
* WARNING:
* DO NOT USE THIS DIRECTLY IN TESTS.
* Use the `getMetadataProvider` from ./wrappers" instead.
*
* NOTE:
* This function uses plain Promises and Promise.all to avoid type issues
* with Cypress. The result needs to wrapped in cy.wrap() to be correctly usable
* in Cypress tests.
*/
export async function getMetadataProvider({
databaseId = SAMPLE_DB_ID,
...rest
}: GetMetadataOpts = {}) {
const metadata = await getMetadata({ databaseId, ...rest });
return Lib.metadataProvider(databaseId, metadata);
}
async function getMetadata({
databaseId = SAMPLE_DB_ID,
tableIds = DEFAULT_TABLE_IDS,
cardIds = DEFAULT_CARD_IDS,
}: GetMetadataOpts = {}) {
// Just one database for now, but wrapped to get the types to work
const databaseIds = [databaseId];
const [databases, tables, cards] = await Promise.all([
// databases
Promise.all(
databaseIds.map((databaseId) =>
GET<Database>(`/api/database/${databaseId}`),
),
),
// cards
Promise.all(
tableIds.map((tableId) =>
GET<Table>(`/api/table/${tableId}/query_metadata`),
),
),
// tables
Promise.all(cardIds.map((cardId) => GET<Card>(`/api/card/${cardId}`))),
]);
const entities = createMockEntitiesState({
databases,
tables,
questions: cards,
});
return getMetadataFromState({ entities });
}
async function GET<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json();
}

View File

@@ -0,0 +1,104 @@
import type * as Lib from "metabase-lib";
import type { CreateTestQueryOpts } from "metabase-lib/test-helpers";
import type { DatabaseId } from "metabase-types/api";
import type { QuestionDetails } from "../api";
import { type Options, question } from "../api/createQuestion";
import type { GetMetadataOpts } from "./types";
import { createNativeQuery, createQuery } from "./wrappers";
/**
* Create a card using the provided details and a MBQL query.
*
* @param details.metadata -
* The metadata provider to use for the query,
* or options used to create one on the fly.
* If no metadata is provided, the query will be created with
* the default metadata provider.
*
* @example
* // If no metadata is provided, the card will be created with
* // the default metadata provider.
* H.createTestQuestion({
* name: "My question",
* query: {
* databaseId: SAMPLE_DATABASE_ID,
* stages: [
* ...
* ]
* }
* })
* })
*
* @example
* // Provide custom metadata entities relevant to your query.
* H.createTestQuestion({
* name: "My question",
* databaseId: SAMPLE_DATABASE_ID,
* query: "SELECT * FROM orders"
* })
* })
*
*/
type CardWithQueryDetails = Omit<QuestionDetails, "dataset_query"> & {
metadata?: GetMetadataOpts | Lib.MetadataProvider;
query: CreateTestQueryOpts;
};
export function createCardWithQuery(
{ metadata = {}, query, ...details }: CardWithQueryDetails,
options: Options = {},
) {
return createQuery(metadata, query).then((dataset_query) =>
question({ ...details, dataset_query }, options),
);
}
type CardWithNativeQueryDetails = Omit<QuestionDetails, "dataset_query"> & {
metadata?: GetMetadataOpts | Lib.MetadataProvider;
databaseId: DatabaseId;
query: string;
};
/**
* Create a card using the provided details and a native query.
*
* @param details.metadata -
* The metadata provider to use for the query,
* or options used to create one on the fly.
* If no metadata is provided, the query will be created with
* the default metadata provider.
*
* @example
* // If no metadata is provided, the card will be created with
* // the default metadata provider.
* H.createCardWithNativeQuery({
* name: "My question",
* databaseId: SAMPLE_DATABASE_ID,
* query: "SELECT * FROM orders"
* })
* })
*
* @example
* // Provide custom metadata entities relevant to your query.
* H.createCardWithNativeQuery({
* name: "My question",
* databaseId: SAMPLE_DATABASE_ID,
* metadata: {
* databaseId: SAMPLE_DATABASE_ID,
* cardIds: [SAMPLE_CARD_ID],
* },
* query: "SELECT * FROM orders"
* })
* })
*/
export function createCardWithNativeQuery(
{ metadata = {}, databaseId, query, ...details }: CardWithNativeQueryDetails,
options: Options = {},
) {
return createNativeQuery(metadata, databaseId, query).then((dataset_query) =>
question({ ...details, dataset_query }, options),
);
}

View File

@@ -0,0 +1,7 @@
import type { CardId, DatabaseId, TableId } from "metabase-types/api";
export type GetMetadataOpts = {
databaseId?: DatabaseId;
tableIds?: TableId[];
cardIds?: CardId[];
};

View File

@@ -0,0 +1,103 @@
import type * as Lib from "metabase-lib";
import type { CreateTestQueryOpts } from "metabase-lib/test-helpers";
import type { DatabaseId, OpaqueDatasetQuery } from "metabase-types/api";
import type { GetMetadataOpts } from "./types";
/**
* Returns a Lib.MetadataProvider instance containing metadata for the
* entities defined in opts.
*
* @param opts.databaseId - The database to fetch metadata for
* @param opts.tableIds - The tables to fetch metadata for
* @param opts.cardIds - Cards to fetch metadata for
*/
export function getMetadataProvider(
opts?: GetMetadataOpts,
): Cypress.Chainable<Lib.MetadataProvider> {
return (
cy
// we need to log in before we can get metadata
.getCookie("metabase.SESSION_ID")
.then(() => _getMetadataProvider({ ...opts }))
);
}
// Helper function which calls getMetadataProvider from ./metadata-provider
// This module needs to imported lazily not to crash Cypress and to avoid importing
// cljs in tests that don't need it.
async function _getMetadataProvider(
opts?: GetMetadataOpts,
): Promise<Lib.MetadataProvider> {
const { getMetadataProvider } = await import("./metadata-provider");
return getMetadataProvider(opts);
}
/**
* Build an MBQL query using a custom DSL that simplifies field selection and creation.
*
* @param metadataProvider - A Lib.MetadataProvider instance or a GetMetadataOpts object that will be user to instantiate a Lib.MetadataProvider
* @param opts - The details of the query to create
*/
export function createQuery(
metadataProvider: Lib.MetadataProvider | GetMetadataOpts,
opts: CreateTestQueryOpts,
): Cypress.Chainable<OpaqueDatasetQuery> {
return ensureProvider(metadataProvider).then((provider) => {
return createTestJsQuery(provider, opts);
});
}
// Helper function which calls createTestJsQuery from metabase-lib/test-helpers.
// This module needs to imported lazily not to crash Cypress and to avoid importing
// cljs in tests that don't need it.
async function createTestJsQuery(
metadataProvider: Lib.MetadataProvider,
opts: CreateTestQueryOpts,
): Promise<OpaqueDatasetQuery> {
const { createTestJsQuery } = await import("metabase-lib/test-helpers");
return createTestJsQuery(metadataProvider, opts);
}
/**
* Build a native MBQL query using a custom DSL that simplifies field selection and creation.
*
* @param metadataProvider - A Lib.MetadataProvider instance or a GetMetadataOpts object that will be user to instantiate a Lib.MetadataProvider
* @param databaseId - The database the query is for
* @param query - The native query string
*/
export function createNativeQuery(
metadataProvider: Lib.MetadataProvider | GetMetadataOpts,
databaseId: DatabaseId,
query: string,
): Cypress.Chainable<OpaqueDatasetQuery> {
return ensureProvider(metadataProvider).then((provider) =>
createTestNativeJsQuery(provider, databaseId, query),
);
}
// Helper function which calls createTestNativeJsQuery from metabase-lib/test-helpers.
// This module needs to imported lazily not to crash Cypress and to avoid importing
// cljs in tests that don't need it.
async function createTestNativeJsQuery(
metadataProvider: Lib.MetadataProvider,
databaseId: DatabaseId,
query: string,
) {
const { createTestNativeJsQuery } = await import("metabase-lib/test-helpers");
return createTestNativeJsQuery(metadataProvider, databaseId, query);
}
function ensureProvider(
providerOrOpts: GetMetadataOpts | Lib.MetadataProvider,
): Cypress.Chainable<Lib.MetadataProvider> {
return isMetadataProvider(providerOrOpts)
? cy.wrap(providerOrOpts)
: getMetadataProvider(providerOrOpts);
}
function isMetadataProvider(
opts: GetMetadataOpts | Lib.MetadataProvider,
): opts is Lib.MetadataProvider {
return "cache" in opts;
}

File diff suppressed because it is too large Load Diff

View File

@@ -110,7 +110,7 @@ describe("scenarios > admin > people", () => {
cy.findByRole("link", { name: /group/i }).click();
cy.findByRole("table").findByRole("link", { name: /data/i }).click();
cy.findByRole("table").findByRole("link", { name: /data$/i }).click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("1 member");

View File

@@ -3,7 +3,7 @@ import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
describe("issue 26470", { tags: "@external" }, () => {
beforeEach(() => {
H.restore("postgres_12");
H.restore("postgres-writable");
cy.signInAsAdmin();
cy.request("POST", "/api/persist/enable");
});

View File

@@ -139,17 +139,14 @@ describe("issue 14636", () => {
"have.value",
"field values scanning",
);
cy.findByPlaceholderText("Filter by status").should(
"have.value",
"Success",
);
getFilterByStatus().should("have.value", "Success");
cy.findAllByTestId("task").should("have.length", 1);
cy.findByTestId("task")
.should("contain.text", "field values scanning")
.and("contain.text", "Sample Database")
.and("contain.text", "Success");
cy.findByPlaceholderText("Filter by status").click();
getFilterByStatus().click();
H.popover().findByText("Failed").click();
cy.location("search").should(
"eq",
@@ -161,12 +158,9 @@ describe("issue 14636", () => {
"No results",
);
cy.findByPlaceholderText("Filter by status")
.parent()
.findByLabelText("Clear")
.click();
getFilterByStatus().parent().findByLabelText("Clear").click();
cy.location("search").should("eq", "?task=field+values+scanning");
cy.findByPlaceholderText("Filter by status").should("have.value", "");
getFilterByStatus().should("have.value", "");
cy.findAllByTestId("task").should("have.length", 1);
cy.findByTestId("task")
.should("contain.text", "field values scanning")
@@ -184,14 +178,14 @@ describe("issue 14636", () => {
cy.log("should reset pagination when changing filters");
cy.visit("/admin/tools/tasks/list?page=1");
cy.findByPlaceholderText("Filter by status").click();
getFilterByStatus().click();
H.popover().findByText("Success").click();
cy.location("search").should("eq", "?status=success");
cy.log("should remove invalid query params");
cy.visit("/admin/tools/tasks/list?status=foobar");
cy.location("search").should("eq", "");
cy.findByPlaceholderText("Filter by status").should("have.value", "");
getFilterByStatus().should("have.value", "");
});
});
@@ -832,54 +826,40 @@ describe("scenarios > admin > tools > task runs filtering", () => {
cy.wait("@getTaskRuns");
cy.log("Filter by run type");
cy.findByPlaceholderText("Filter by run type").click();
getFilterByRun().click();
H.popover().findByText("Sync").click();
cy.location("search").should("contain", "run-type=sync");
cy.wait("@getTaskRuns");
cy.log("Filter by started at");
getFilterByStartedAt().click();
H.popover().findByText("Previous 30 days").click();
cy.location("search").should("contain", "started-at=past30days");
cy.wait("@getTaskRuns");
cy.wait("@getEntities");
cy.log("Filter by entity");
cy.findByPlaceholderText("Filter by entity").click();
getFilterByEntity().click();
H.popover().findByText("Sample Database").click();
cy.location("search").should("contain", "entity-type=database");
cy.location("search").should("contain", "entity-id=1");
cy.wait("@getTaskRuns");
cy.log("Filter by status");
cy.findByPlaceholderText("Filter by status").click();
getFilterByStatus().click();
H.popover().findByText("Success").click();
cy.location("search").should("contain", "status=success");
cy.wait("@getTaskRuns");
cy.log("Clear all filters");
cy.findByPlaceholderText("Filter by run type")
.parent()
.findByLabelText("Clear")
.click();
cy.findByPlaceholderText("Filter by status")
.parent()
.findByLabelText("Clear")
.click();
getFilterByRun().parent().findByLabelText("Clear").click();
getFilterByStartedAt().parent().findByLabelText("Clear").click();
getFilterByStatus().parent().findByLabelText("Clear").click();
cy.location("search").should("eq", "");
});
it("entity picker should be disabled/enabled based on run type and entities availability", () => {
it("entity picker should be disabled/enabled based on run type, started at and entities availability", () => {
cy.visit("/admin/tools/tasks/runs");
cy.wait("@getTaskRuns");
cy.log("Should be disabled when no run type is selected");
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
cy.log("Should show tooltip 'Select a run type first' when hovering");
cy.findByPlaceholderText("Filter by entity").trigger("mouseenter", {
force: true,
});
cy.findByRole("tooltip").should("have.text", "Select a run type first");
cy.findByPlaceholderText("Filter by entity").trigger("mouseleave", {
force: true,
});
cy.log("Should show loader while loading entities");
cy.intercept("GET", "/api/task/runs/entities?*", {
body: [
{
@@ -891,10 +871,24 @@ describe("scenarios > admin > tools > task runs filtering", () => {
delay: 500,
}).as("getEntitiesDelayed");
cy.findByPlaceholderText("Filter by run type").click();
cy.log("Should be disabled when no run type is selected");
getFilterByEntity().should("be.disabled");
assertFilterByEntityTooltipText("Select a run type first");
getFilterByRun().click();
H.popover().findByText("Sync").click();
cy.findByPlaceholderText("Filter by entity")
cy.log("Should be still disabled until started at is selected");
getFilterByEntity().should("be.disabled");
cy.log("Should show tooltip 'Select a start time' when hovering");
assertFilterByEntityTooltipText("Select a start time first");
getFilterByStartedAt().click();
H.popover().findByText("Previous 30 days").click();
cy.log("Should show loader while loading entities");
getFilterByEntity()
.should("be.disabled")
.closest(".mb-mantine-Select-root")
.find(".mb-mantine-Loader-root")
@@ -903,30 +897,56 @@ describe("scenarios > admin > tools > task runs filtering", () => {
cy.wait("@getEntitiesDelayed");
cy.log("Should be enabled after entities are loaded");
cy.findByPlaceholderText("Filter by entity").should("not.be.disabled");
getFilterByEntity().should("not.be.disabled");
cy.log("Should clear and disable entity filter when run type is cleared");
cy.findByPlaceholderText("Filter by run type")
.parent()
.findByLabelText("Clear")
.click();
getFilterByRun().parent().findByLabelText("Clear").click();
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
cy.findByPlaceholderText("Filter by entity").should("have.value", "");
getFilterByEntity().should("be.disabled");
getFilterByEntity().should("have.value", "");
cy.log("Should clear and disable entity filter when started at is cleared");
getFilterByRun().click();
H.popover().findByText("Sync").click();
getFilterByStartedAt().parent().findByLabelText("Clear").click();
getFilterByEntity().should("be.disabled");
getFilterByEntity().should("have.value", "");
cy.log("Should show tooltip 'No entities available' when no entities");
cy.intercept("GET", "/api/task/runs/entities?*", {
body: [],
}).as("getEmptyEntities");
cy.findByPlaceholderText("Filter by run type").click();
getFilterByRun().click();
H.popover().findByText("Alert").click();
getFilterByStartedAt().click();
H.popover().findByText("Previous 30 days").click();
cy.wait("@getEmptyEntities");
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
cy.findByPlaceholderText("Filter by entity").trigger("mouseenter", {
force: true,
});
cy.findByRole("tooltip").should("have.text", "No entities available");
getFilterByEntity().should("be.disabled");
assertFilterByEntityTooltipText("No entities available");
});
});
function getFilterByRun() {
return cy.findByPlaceholderText("Filter by run type");
}
function getFilterByStartedAt() {
return cy.findByPlaceholderText("Filter by started at");
}
function getFilterByEntity() {
return cy.findByPlaceholderText("Filter by entity");
}
function getFilterByStatus() {
return cy.findByPlaceholderText("Filter by status");
}
function assertFilterByEntityTooltipText(text: string) {
getFilterByEntity().trigger("mouseenter", {
force: true,
});
cy.findByRole("tooltip").should("have.text", text);
}

View File

@@ -203,6 +203,7 @@ describe("issue 56567", () => {
["All Users", allUsersPermission],
["collection", "Curate"],
["data", "No access"],
["Data Analysts", "No access"],
["nosql", "No access"],
["readonly", "View"],
];

View File

@@ -1525,6 +1525,114 @@ SELECT 'group_2', 'sub_group_2', 52, 'group_2__sub_group_2';
const scalarContainer = () => cy.findByTestId("scalar-container");
const previousValue = () => cy.findByTestId("scalar-previous-value");
describe("issue 67432", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("should copy sorted table data in correct sorted order (metabase#67432)", () => {
H.grantClipboardPermissions();
const ROWS_LIMIT = 5;
const questionDetails = {
name: "67432 Question",
query: {
"source-table": PRODUCTS_ID,
fields: [
["field", PRODUCTS.ID, null],
["field", PRODUCTS.TITLE, null],
["field", PRODUCTS.CATEGORY, null],
],
limit: ROWS_LIMIT,
},
};
H.createQuestionAndDashboard({
questionDetails,
cardDetails: {
size_x: 16,
size_y: 10,
},
}).then(({ body: { dashboard_id } }) => {
H.visitDashboard(dashboard_id);
});
// Wait for table to load
H.tableInteractiveBody().should("be.visible");
// Sort by Category column (descending first click)
H.tableHeaderClick("Category");
// Wait for sort to apply - the sort icon should appear
H.tableHeaderColumn("Category")
.closest("[data-testid=header-cell]")
.icon("chevrondown")
.should("exist");
// Collect the visual order of categories from the table
const visualCategories = [];
H.tableInteractiveBody()
.find('[data-column-id="CATEGORY"]')
.each(($cell) => {
visualCategories.push($cell.text());
})
.then(() => {
// Select multiple cells across rows by dragging
const getNonPKCells = () =>
H.tableInteractiveBody().find(
'[data-selectable-cell]:not([data-column-id="ID"])',
);
// Select cells in first two rows (4 cells: Title+Category for 2 rows)
getNonPKCells()
.eq(0)
.trigger("mousedown", { which: 1 })
.then(() => {
const lastCellIndex = ROWS_LIMIT * 2 - 1;
getNonPKCells()
.should("have.length", ROWS_LIMIT * 2)
.eq(lastCellIndex)
.trigger("mouseover", { buttons: 1 });
getNonPKCells()
.should("have.length", ROWS_LIMIT * 2)
.eq(lastCellIndex)
.trigger("mouseup");
});
// Copy to clipboard
cy.realPress(["Meta", "c"]);
// Verify clipboard content has rows in sorted order
H.readClipboard().then((clipboardText) => {
// The clipboard should contain properly tab-separated content
// with newlines between rows (not a single cell)
const lines = clipboardText.split("\n");
// Should have header row + data rows (at least 6 lines: header + 5 data rows)
expect(lines.length).to.be.eq(ROWS_LIMIT + 1);
// Header should be tab-separated with both columns
const headerCells = lines[0].split("\t");
expect(headerCells).to.include("Title");
expect(headerCells).to.include("Category");
// Verify each data row is tab-separated and in the correct sorted order
const clipboardCategories = lines.slice(1).map((line) => {
const cells = line.split("\t");
// Category is the second column
return cells[1];
});
// The categories in clipboard should match the visual order
for (let i = 0; i < clipboardCategories.length; i++) {
expect(clipboardCategories[i]).to.equal(visualCategories[i]);
}
});
});
});
});
describe("issue 63416", () => {
const questionDetails = {
name: "63416 Question",

View File

@@ -22,6 +22,7 @@ interface Table {
describe("bulk table operations", () => {
beforeEach(() => {
H.restore();
H.resetSnowplow();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
cy.intercept("POST", "/api/ee/data-studio/table/sync-schema").as(
@@ -63,6 +64,9 @@ describe("bulk table operations", () => {
cy.findByRole("heading", { name: /2 tables selected/ });
cy.findByRole("button", { name: /Sync settings/ }).click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_sync_settings_clicked",
});
cy.findByRole("button", { name: /Sync table schemas/ }).click();
cy.findByRole("button", { name: /Sync triggered!/ }).should("be.visible");
cy.get<number[]>("@tableIds").then((tableIds) => {
@@ -73,6 +77,10 @@ describe("bulk table operations", () => {
},
);
});
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_schema_sync_started",
result: "success",
});
cy.findByRole("button", { name: /Re-scan tables/ }).click();
cy.findByRole("button", { name: /Scan triggered!/ }).should("be.visible");
@@ -85,6 +93,10 @@ describe("bulk table operations", () => {
},
);
});
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_fields_rescan_started",
result: "success",
});
cy.findByRole("button", { name: /Discard cached field values/ }).click();
cy.findByRole("button", { name: /Discard triggered!/ }).should(
@@ -98,6 +110,10 @@ describe("bulk table operations", () => {
expect(response?.statusCode).to.eq(204);
});
});
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_field_values_discard_started",
result: "success",
});
});
it(
@@ -134,6 +150,9 @@ describe("bulk table operations", () => {
cy.findByRole("button", { name: /Unpublish/ }).click();
H.modal().findByText("Unpublish these tables").click();
cy.wait("@unpublishTables");
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_unpublished",
});
H.DataStudio.nav().findByLabelText("Library").click();
H.DataStudio.Library.libraryPage().within(() => {
@@ -155,15 +174,35 @@ describe("bulk table operations", () => {
H.selectHasValue("Owner", "").click();
H.selectDropdown().contains("Bobby Tables").click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_attribute_updated",
event_detail: "owner",
result: "success",
});
H.selectHasValue("Visibility type", "").click();
H.selectDropdown().contains("Gold").click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_attribute_updated",
event_detail: "layer",
result: "success",
});
H.selectHasValue("Entity type", "").click();
H.selectDropdown().contains("Person").click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_attribute_updated",
event_detail: "entity_type",
result: "success",
});
H.selectHasValue("Source", "").click();
H.selectDropdown().contains("Ingested").click();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_bulk_attribute_updated",
event_detail: "data_source",
result: "success",
});
H.undoToastList().should("have.length", 4);
TablePicker.getTable("Orders")
.findByTestId("table-owner")

View File

@@ -7,6 +7,7 @@ describe("Search", () => {
beforeEach(() => {
cy.signInAsAdmin();
H.restore("postgres-writable");
H.resetSnowplow();
H.activateToken("bleeding-edge");
H.resetTestTable({ type: "postgres", table: "multi_schema" });
H.resyncDatabase({ dbId: WRITABLE_DB_ID });
@@ -19,6 +20,9 @@ describe("Search", () => {
TablePicker.getTables().should("have.length", 3);
TablePicker.getTable("Analytic Events").should("be.visible");
TablePicker.getTable("Animals").should("be.visible");
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_picker_search_performed",
});
});
it("should support wildcard search with *", () => {
@@ -26,10 +30,19 @@ describe("Search", () => {
TablePicker.getSearchInput().type("irds");
TablePicker.get().findByText("No tables found").should("be.visible");
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_picker_search_performed",
});
TablePicker.getSearchInput().clear().type("*irds");
TablePicker.getTables().should("have.length", 1);
TablePicker.getTable("Birds").should("be.visible");
H.expectUnstructuredSnowplowEvent(
{
event: "data_studio_table_picker_search_performed",
},
2,
);
});
it("should allow using shift key to select multiple tables", () => {
@@ -105,6 +118,8 @@ describe("Search", () => {
it("should select/deselect databases and schemas", () => {
H.DataModel.visitDataStudio();
TablePicker.getSearchInput().type("a");
// wait for the tables to be loaded
TablePicker.getTables().should("have.length", 4);
const postgres = "Writable Postgres12";
const sampleDatabaseName = "Sample Database";
const domesticSchema = "Domestic";

View File

@@ -1,24 +1,23 @@
import {
SAMPLE_DB_ID,
SAMPLE_DB_SCHEMA_ID,
USER_GROUPS,
WRITABLE_DB_ID,
} from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
import { DataModel } from "e2e/support/helpers";
import {
NODATA_USER_ID,
ORDERS_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
import type { TableId } from "metabase-types/api";
const { H } = cy;
const { TablePicker, TableSection, FieldSection, PreviewSection } = H.DataModel;
const { ALL_USERS_GROUP } = USER_GROUPS;
const {
FEEDBACK,
FEEDBACK_ID,
ORDERS,
ORDERS_ID,
PEOPLE_ID,
PRODUCTS,
PRODUCTS_ID,
REVIEWS,
@@ -27,12 +26,10 @@ const {
const MYSQL_DB_ID = SAMPLE_DB_ID + 1;
const MYSQL_DB_SCHEMA_ID = `${MYSQL_DB_ID}:`;
const CUSTOM_MAPPING_ERROR =
"You need unrestricted data access on this table to map custom display values.";
describe("scenarios > data studio > datamodel", () => {
beforeEach(() => {
H.restore();
H.resetSnowplow();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
@@ -574,6 +571,12 @@ describe("scenarios > data studio > datamodel", () => {
openFilterPopover();
selectFilterOption("Visibility type", "Gold");
applyFilters();
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_picker_filters_applied",
});
H.expectUnstructuredSnowplowEvent({
event: "data_studio_table_picker_search_performed",
});
cy.get<TableId>("@goldTableId").then(expectTableVisible);
cy.get<TableId>("@silverTableId").then(expectTableNotVisible);
@@ -1064,31 +1067,6 @@ describe("scenarios > data studio > datamodel", () => {
cy.findByText("New description").should("be.visible");
});
it("should allow changing the table name with data model permissions only", () => {
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
});
TableSection.getNameInput().clear().type("New orders").blur();
cy.wait("@updateTable");
verifyAndCloseToast("Table name updated");
TableSection.getNameInput().should("have.value", "New orders");
cy.signOut();
cy.signInAsNormalUser();
H.startNewQuestion();
H.miniPicker().within(() => {
cy.findByText("Sample Database").click();
cy.findByText("People").should("be.visible");
cy.findByText("New orders").should("be.visible");
});
});
it("should allow clearing the table description", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -1107,6 +1085,76 @@ describe("scenarios > data studio > datamodel", () => {
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("No description yet").should("be.visible");
});
it("should allow analysts to edit all table metadata even without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
});
cy.log("change table name");
TableSection.getNameInput().clear().type("Analyst Orders").blur();
cy.wait("@updateTable");
verifyAndCloseToast("Table name updated");
TableSection.getNameInput().should("have.value", "Analyst Orders");
cy.log("change table description");
TableSection.getDescriptionInput()
.clear()
.type("Description by analyst")
.blur();
cy.wait("@updateTable");
verifyAndCloseToast("Table description updated");
TableSection.getDescriptionInput().should(
"have.value",
"Description by analyst",
);
cy.log("change field name");
TableSection.getFieldNameInput("Tax")
.clear()
.type("Analyst Tax")
.blur();
cy.wait("@updateField");
verifyAndCloseToast("Name of Tax updated");
TableSection.getFieldNameInput("Analyst Tax").should("be.visible");
cy.log("change field description");
TableSection.getFieldDescriptionInput("Total")
.clear()
.type("Total edited by analyst")
.blur();
cy.wait("@updateField");
verifyAndCloseToast("Description of Total updated");
cy.log("verify changes in data reference as admin");
cy.signInAsAdmin();
cy.visit(`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}`);
cy.get("main").within(() => {
cy.findByText("Analyst Orders").should("be.visible");
cy.findByText("Description by analyst").should("be.visible");
});
cy.log("verify changes in question picker as normal user");
cy.signInAsNormalUser();
H.startNewQuestion();
H.miniPicker().within(() => {
cy.findByText("Sample Database").click();
cy.findByText("People").should("be.visible");
cy.findByText("Analyst Orders").should("be.visible");
});
cy.log("verify field changes in table visualization");
H.openOrdersTable();
H.tableHeaderColumn("Analyst Tax").should("be.visible");
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
"not.exist",
);
});
});
describe("Field name and description", () => {
@@ -1139,43 +1187,6 @@ describe("scenarios > data studio > datamodel", () => {
);
});
it("should allow changing the field name with data model permissions only", () => {
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
});
TableSection.getFieldNameInput("Tax").clear().type("New tax").blur();
cy.wait("@updateField");
verifyAndCloseToast("Name of Tax updated");
TableSection.getFieldNameInput("New tax").should("be.visible");
TableSection.getField("New tax").should("be.visible");
cy.log("verify preview");
TableSection.clickField("New tax");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify viz as normal user");
cy.signInAsNormalUser();
H.openOrdersTable();
H.tableHeaderColumn("New tax").should("be.visible");
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
"not.exist",
);
});
it("should allow changing the field description", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -1242,6 +1253,83 @@ describe("scenarios > data studio > datamodel", () => {
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("No description yet").should("be.visible");
});
it("should allow analysts to edit field metadata but not preview data without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
});
cy.log("change field name from table section");
TableSection.getFieldNameInput("Tax")
.clear()
.type("Analyst Tax Field")
.blur();
cy.wait("@updateField");
verifyAndCloseToast("Name of Tax updated");
TableSection.getFieldNameInput("Analyst Tax Field").should(
"be.visible",
);
TableSection.getField("Analyst Tax Field").should("be.visible");
cy.log("change field description from table section");
TableSection.getFieldDescriptionInput("Total")
.clear()
.type("Analyst total description")
.blur();
cy.wait("@updateField");
verifyAndCloseToast("Description of Total updated");
TableSection.getFieldDescriptionInput("Total").should(
"have.value",
"Analyst total description",
);
cy.log("navigate to field detail and change semantic type");
TableSection.clickField("Discount");
FieldSection.getSemanticTypeInput()
.should("have.value", "Discount")
.click();
H.popover().findByText("Currency").click();
cy.wait("@updateField");
verifyAndCloseToast("Semantic type of Discount updated");
FieldSection.getSemanticTypeInput().should("have.value", "Currency");
cy.log("verify table preview is blocked without data permissions");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify detail preview is also blocked");
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify field changes in data reference as admin");
cy.signInAsAdmin();
cy.visit(
`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/fields/${ORDERS.TOTAL}`,
);
cy.get("main").within(() => {
cy.findByText("Total").should("be.visible");
cy.findByText("Analyst total description").should("be.visible");
});
cy.log("verify field changes in table visualization as normal user");
cy.signInAsNormalUser();
H.openOrdersTable();
H.tableHeaderColumn("Analyst Tax Field").should("be.visible");
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
"not.exist",
);
H.tableHeaderColumn("Discount ($)").should("be.visible");
});
});
describe("Sorting", () => {
@@ -1534,46 +1622,6 @@ describe("scenarios > data studio > datamodel", () => {
);
});
it("should allow changing the field name with data model permissions only", () => {
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
fieldId: ORDERS.TOTAL,
});
FieldSection.getNameInput().clear().type("New total").blur();
cy.wait("@updateField");
verifyAndCloseToast("Name of Total updated");
FieldSection.getNameInput().should("have.value", "New total");
TableSection.getFieldNameInput("New total")
.scrollIntoView()
.should("be.visible");
cy.log("verify preview");
TableSection.clickField("New total");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify viz as normal user");
cy.signInAsNormalUser();
H.openOrdersTable();
H.tableHeaderColumn("New total").should("be.visible");
H.tableHeaderColumn("Total", { scrollIntoView: false }).should(
"not.exist",
);
});
it("should allow changing the field description", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -1981,84 +2029,6 @@ describe("scenarios > data studio > datamodel", () => {
cy.findByLabelText("Left column").should("contain.text", "User ID");
});
it("should allow to change the field foreign key target with no permissions to Reviews table", () => {
setDataModelPermissions({
tableIds: [ORDERS_ID, PRODUCTS_ID, PEOPLE_ID],
});
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
fieldId: ORDERS.USER_ID,
});
FieldSection.getSemanticTypeFkTarget()
.should("have.value", "People → ID")
.click();
H.popover().within(() => {
cy.findByText("Reviews → ID").should("not.exist");
cy.findByText("Products → ID").click();
});
cy.wait("@updateField");
H.undoToast().should(
"contain.text",
"Semantic type of User ID updated",
);
FieldSection.getSemanticTypeFkTarget().should(
"have.value",
"Products → ID",
);
cy.log("verify preview");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify viz as normal user");
cy.signInAsNormalUser();
H.openTable({
database: SAMPLE_DB_ID,
table: ORDERS_ID,
mode: "notebook",
});
cy.icon("join_left_outer").click();
H.miniPicker().within(() => {
cy.findByText("Sample Database").click();
cy.findByText("Products").click();
});
cy.findByLabelText("Left column").should("contain.text", "User ID");
});
it("should not allow setting foreign key target for inaccessible tables", () => {
setDataModelPermissions({ tableIds: [REVIEWS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.PRODUCT_ID,
});
FieldSection.getDisplayValuesInput().click();
H.popover().within(() => {
cy.findByRole("option", { name: /Use original value/ })
.should("be.visible")
.and("not.have.attr", "data-combobox-disabled");
cy.findByRole("option", { name: /Use foreign key/ })
.should("be.visible")
.and("have.attr", "data-combobox-disabled", "true");
});
});
it("should allow to change the type to 'Currency' and choose the currency (metabase#59052)", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -2238,6 +2208,56 @@ describe("scenarios > data studio > datamodel", () => {
},
);
});
it("should allow analysts to change the foreign key target without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: ORDERS_ID,
fieldId: ORDERS.USER_ID,
});
FieldSection.getSemanticTypeFkTarget()
.should("have.value", "People → ID")
.click();
H.popover().within(() => {
cy.findByText("Reviews → ID").should("be.visible");
cy.findByText("Products → ID").click();
});
cy.wait("@updateField");
H.undoToast().should(
"contain.text",
"Semantic type of User ID updated",
);
FieldSection.getSemanticTypeFkTarget().should(
"have.value",
"Products → ID",
);
cy.log("verify preview is blocked without data permissions");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify FK target change works in query builder as normal user");
cy.signInAsNormalUser();
H.openTable({
database: SAMPLE_DB_ID,
table: ORDERS_ID,
mode: "notebook",
});
cy.icon("join_left_outer").click();
H.miniPicker().within(() => {
cy.findByText("Sample Database").click();
cy.findByText("Products").click();
});
cy.findByLabelText("Left column").should("contain.text", "User ID");
});
});
describe("Behavior", () => {
@@ -2653,34 +2673,6 @@ describe("scenarios > data studio > datamodel", () => {
.and("have.value", "Title");
});
it("should allow to change foreign key target for accessible tables", () => {
setDataModelPermissions({
tableIds: [ORDERS_ID, REVIEWS_ID, PRODUCTS_ID],
});
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.PRODUCT_ID,
});
FieldSection.getDisplayValuesInput().click();
H.popover().findByText("Use foreign key").click();
H.popover().findByText("Title").click();
cy.wait("@updateFieldDimension");
H.undoToast().should(
"contain.text",
"Display values of Product ID updated",
);
cy.signInAsNormalUser();
H.openReviewsTable({ limit: 1 });
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Rustic Paper Wallet").should("be.visible");
});
it("should allow 'Custom mapping' null values", () => {
const remappedNullValue = "nothin";
@@ -2868,54 +2860,6 @@ describe("scenarios > data studio > datamodel", () => {
});
});
it("should show a proper error message when using custom mapping", () => {
setDataModelPermissions({ tableIds: [REVIEWS_ID] });
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
FieldSection.getDisplayValuesInput().click();
H.popover().within(() => {
cy.findByRole("option", { name: /Use original value/ })
.should("be.visible")
.and("not.have.attr", "data-combobox-disabled");
cy.findByRole("option", { name: /Custom mapping/ })
.should("be.visible")
.and("have.attr", "data-combobox-disabled", "true");
});
cy.signInAsAdmin();
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
FieldSection.getDisplayValuesInput().click();
H.popover().findByText("Custom mapping").click();
cy.wait("@updateFieldDimension");
H.undoToast().should(
"contain.text",
"Display values of Rating updated",
);
cy.signIn("none");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText(CUSTOM_MAPPING_ERROR).should("exist");
});
it("should allow 'Custom mapping' option only for 'Search box' filtering type (metabase#16322)", () => {
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
@@ -3009,6 +2953,105 @@ describe("scenarios > data studio > datamodel", () => {
.eq(10) // 1st data row, 2nd column (User ID)
.should("have.text", "2023-10-07T01:34:35.462-07:00");
});
it("should allow analysts to change display values to use foreign key without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.PRODUCT_ID,
});
FieldSection.getDisplayValuesInput().click();
H.popover().findByText("Use foreign key").click();
H.popover().findByText("Title").click();
cy.wait("@updateFieldDimension");
H.undoToast().should(
"contain.text",
"Display values of Product ID updated",
);
FieldSection.getDisplayValuesInput().should(
"have.value",
"Use foreign key",
);
FieldSection.getDisplayValuesFkTargetInput().should(
"have.value",
"Title",
);
cy.log("verify preview is blocked without data permissions");
FieldSection.getPreviewButton().click();
cy.wait("@dataset");
PreviewSection.get()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.log("verify display value change works as normal user");
cy.signInAsNormalUser();
H.openReviewsTable({ limit: 1 });
H.main().findByText("Rustic Paper Wallet").should("be.visible");
});
it("should disable custom mapping for analysts without data access", () => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
cy.log("verify custom mapping is disabled without data access");
FieldSection.getDisplayValuesInput().click();
H.popover().within(() => {
cy.findByRole("option", { name: /Use original value/ })
.should("be.visible")
.and("not.have.attr", "data-combobox-disabled");
cy.findByRole("option", { name: /Custom mapping/ })
.should("be.visible")
.and("have.attr", "data-combobox-disabled", "true");
});
cy.log("verify admin can set up custom mapping");
cy.signInAsAdmin();
H.DataModel.visitDataStudio({
databaseId: SAMPLE_DB_ID,
schemaId: SAMPLE_DB_SCHEMA_ID,
tableId: REVIEWS_ID,
fieldId: REVIEWS.RATING,
});
FieldSection.getDisplayValuesInput().click();
H.popover().findByText("Custom mapping").click();
cy.wait("@updateFieldValues");
H.undoToast().should(
"contain.text",
"Display values of Rating updated",
);
H.undoToast().icon("close").click({ force: true });
H.modal().within(() => {
cy.findByDisplayValue("1").click().clear().type("Terrible");
cy.findByDisplayValue("5").click().clear().type("Amazing");
cy.button("Save").click();
});
cy.wait("@updateFieldValues");
H.undoToast().should(
"contain.text",
"Display values of Rating updated",
);
cy.log("verify custom mapping works as normal user");
cy.signInAsNormalUser();
H.openReviewsTable();
H.main().findByText("Terrible").should("be.visible");
H.main().findAllByText("Amazing").should("be.visible");
});
});
describe("Unfold JSON", { tags: "@external" }, () => {
@@ -3480,7 +3523,7 @@ describe("scenarios > data studio > datamodel", () => {
fieldId: ORDERS.PRODUCT_ID,
});
DataModel.TableSection.getVisibilityTypeInput().click();
H.DataModel.TableSection.getVisibilityTypeInput().click();
H.popover().findByText("Copper").click();
cy.wait("@updateTable");
@@ -3868,27 +3911,6 @@ function clickAway() {
cy.get("body").click(0, 0);
}
const setDataModelPermissions = ({
tableIds = [],
}: {
tableIds: TableId[];
}) => {
const permissions = Object.fromEntries(tableIds.map((id) => [id, "all"]));
// @ts-expect-error invalid cy.updatePermissionsGraph typing
cy.updatePermissionsGraph({
[ALL_USERS_GROUP]: {
[SAMPLE_DB_ID]: {
"data-model": {
schemas: {
PUBLIC: permissions,
},
},
},
},
});
};
type TableSummary = {
id: TableId;
db_id: number;

View File

@@ -1,15 +1,11 @@
import {
SAMPLE_DB_ID,
SAMPLE_DB_SCHEMA_ID,
USER_GROUPS,
} from "e2e/support/cypress_data";
import { SAMPLE_DB_ID, SAMPLE_DB_SCHEMA_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { NODATA_USER_ID } from "e2e/support/cypress_sample_instance_data";
const { H } = cy;
const { SegmentList, SegmentEditor, SegmentRevisionHistory } = H.DataModel;
const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, PEOPLE, PEOPLE_ID } =
SAMPLE_DATABASE;
const { ALL_USERS_GROUP } = USER_GROUPS;
describe(
"scenarios > data studio > data model > segments",
@@ -614,12 +610,12 @@ describe(
});
});
describe("Readonly access with data model permissions", () => {
describe("Readonly access for data analysts", () => {
it("should show segments in list but hide New segment button for non-admin", () => {
createTestSegment({ name: "Readonly Test Segment" });
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
cy.log("verify segment is visible in list");
visitDataStudioSegments(ORDERS_ID);
@@ -636,9 +632,7 @@ describe(
cy.visit(
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/segments/new`,
);
H.main()
.findByText("Sorry, you don’t have permission to see that.")
.should("be.visible");
cy.url().should("include", "/unauthorized");
});
it("should display segment detail in readonly mode for non-admin", () => {
@@ -648,8 +642,8 @@ describe(
});
cy.get<number>("@segmentId").then((segmentId) => {
setDataModelPermissions({ tableIds: [ORDERS_ID] });
cy.signIn("none");
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
visitDataModelSegment(ORDERS_ID, segmentId);
@@ -766,20 +760,3 @@ function verifySegmentNotInQueryBuilder(
H.getNotebookStep("data").button("Filter").click();
H.popover().findByText(segmentName).should("not.exist");
}
function setDataModelPermissions({ tableIds = [] }: { tableIds: number[] }) {
const permissions = Object.fromEntries(tableIds.map((id) => [id, "all"]));
// @ts-expect-error invalid cy.updatePermissionsGraph typing
cy.updatePermissionsGraph({
[ALL_USERS_GROUP]: {
[SAMPLE_DB_ID]: {
"data-model": {
schemas: {
PUBLIC: permissions,
},
},
},
},
});
}

View File

@@ -22,14 +22,9 @@ describe("scenarios > data studio > library", () => {
H.DataModel.visitDataStudio();
H.DataStudio.nav().findByLabelText("Library").click();
cy.log("Closing the modal should send you back");
H.modal().button("Cancel").click();
H.DataModel.get().should("exist");
cy.log("Create library via modal");
H.DataStudio.nav().findByLabelText("Library").click();
H.modal().within(() => {
cy.findByText("Create your Library").should("be.visible");
cy.log("Create library via inline empty state");
H.DataStudio.Library.libraryPage().within(() => {
cy.findByText("A source of truth for analytics").should("be.visible");
cy.findByText("Create my Library").click();
});
@@ -111,7 +106,7 @@ describe("scenarios > data studio > library", () => {
cy.log("Publish a table from the 'New' menu");
H.DataStudio.Library.newButton().click();
H.popover().findByText("Publish a table").click();
H.popover().findByText("Published table").click();
cy.log("Select a table and click 'Publish'");
H.entityPickerModalItem(3, "Orders").click();
@@ -127,7 +122,7 @@ describe("scenarios > data studio > library", () => {
"Verify tables in the entity picker are disabled if already published",
);
H.DataStudio.Library.newButton().click();
H.popover().findByText("Publish a table").click();
H.popover().findByText("Published table").click();
H.entityPickerModalItem(3, "Orders").should("have.attr", "data-disabled");
H.entityPickerModalItem(3, "People").should(
"not.have.attr",
@@ -198,7 +193,7 @@ describe("scenarios > data studio > library", () => {
cy.log("Publish a table via the +New menu");
H.DataStudio.Library.newButton().click();
H.popover().findByText("Publish a table").click();
H.popover().findByText("Published table").click();
H.entityPickerModalItem(3, "Orders").click();
H.entityPickerModal().button("Publish").click();

View File

@@ -1,5 +1,6 @@
import { SAMPLE_DB_ID, SAMPLE_DB_SCHEMA_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { NODATA_USER_ID } from "e2e/support/cypress_sample_instance_data";
const { H } = cy;
const { MeasureList, MeasureEditor, MeasureRevisionHistory } = H.DataModel;
@@ -389,6 +390,76 @@ describe("scenarios > data studio > data model > measures", () => {
.should("be.visible");
});
});
describe("Readonly access for data analysts", () => {
it("should show measures in list but hide New measure button for non-admin", () => {
createTestMeasure({ name: "Readonly Test Measure" });
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
cy.log("verify measure is visible in list");
visitDataStudioMeasures(ORDERS_ID);
MeasureList.getMeasure("Readonly Test Measure")
.scrollIntoView()
.should("be.visible");
cy.log("verify New measure button is not visible");
MeasureList.get()
.findByRole("link", { name: /New measure/i })
.should("not.exist");
cy.log("verify direct navigation to new measure page is blocked");
cy.visit(
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/measures/new`,
);
cy.url().should("include", "/unauthorized");
});
it("should display measure detail in readonly mode for non-admin", () => {
createTestMeasure({
name: "Readonly Detail Measure",
description: "Test description for readonly",
});
cy.get<number>("@measureId").then((measureId) => {
H.setUserAsAnalyst(NODATA_USER_ID);
cy.signIn("nodata");
visitDataModelMeasure(ORDERS_ID, measureId);
cy.log("verify measure name input is disabled");
MeasureEditor.get()
.findByDisplayValue("Readonly Detail Measure")
.should("be.disabled");
cy.log("verify description is displayed as plain text");
MeasureEditor.get().findByText("Description").should("be.visible");
MeasureEditor.get()
.findByText("Test description for readonly")
.should("be.visible");
cy.log("verify Save button is not visible");
MeasureEditor.get()
.findByRole("button", { name: /Save/i })
.should("not.exist");
cy.log("verify Remove measure option is hidden in actions menu");
MeasureEditor.getActionsButton().click();
H.popover().findByText("Preview").should("be.visible");
H.popover().findByText("Remove measure").should("not.exist");
cy.realPress("Escape");
cy.log("verify revision history is still accessible");
MeasureEditor.getRevisionHistoryTab().click();
MeasureRevisionHistory.get().within(() => {
cy.findByText(/created this measure/i)
.scrollIntoView()
.should("be.visible");
});
});
});
});
});
function visitDataStudioTable(tableId: number) {

View File

@@ -19,7 +19,7 @@ describe("scenarios > data studio > snippets", () => {
H.DataStudio.Library.visit();
H.DataStudio.Library.newButton().click();
H.popover().findByText("New snippet").click();
H.popover().findByText("Snippet").click();
H.DataStudio.Snippets.newPage().should("be.visible");
H.DataStudio.Snippets.saveButton().should("be.disabled");
@@ -121,6 +121,42 @@ describe("scenarios > data studio > snippets", () => {
H.DataStudio.Snippets.editPage().should("be.visible");
});
it("should preserve unsaved content changes when description or name is edited", () => {
cy.log("Navigate to a snippet and edit its content");
H.createSnippet({
name: "Test snippet",
content: "SELECT * FROM orders",
});
H.DataStudio.Library.visit();
H.DataStudio.Library.libraryPage().findByText("Test snippet").click();
H.DataStudio.Snippets.editor.type("1");
cy.log("Edit its name");
cy.findByPlaceholderText("Name").type("1").blur();
H.undoToast().findByText("Snippet name updated").should("be.visible");
H.undoToast().icon("close").click();
cy.log("Edit its description");
H.DataStudio.Snippets.descriptionInput().type("desc").blur();
H.undoToast()
.findByText("Snippet description updated")
.should("be.visible");
H.undoToast().icon("close").click();
cy.log("Verify unsaved changes are preserved");
H.DataStudio.Snippets.editor
.value()
.should("eq", "SELECT * FROM orders1");
cy.log(
"Verify Save button saves the content without reverting the name and description changes",
);
H.DataStudio.Snippets.saveButton().click();
H.undoToast().findByText("Snippet content updated").should("be.visible");
cy.findByPlaceholderText("Name").should("have.value", "Test snippet1");
H.DataStudio.Snippets.editPage().findByText("desc").should("be.visible");
});
});
describe("description", () => {
@@ -178,7 +214,7 @@ describe("scenarios > data studio > snippets", () => {
H.DataStudio.Library.visit();
H.DataStudio.Library.newButton().click();
H.popover().findByText("New snippet folder").click();
H.popover().findByText("Snippet folder").click();
H.modal().within(() => {
cy.findByLabelText("Give your folder a name").type("Test Folder");
@@ -194,7 +230,7 @@ describe("scenarios > data studio > snippets", () => {
.should("be.visible");
H.DataStudio.Library.newButton().click();
H.popover().findByText("New snippet").click();
H.popover().findByText("Snippet").click();
H.DataStudio.Snippets.nameInput().clear().type("Folder snippet");
H.DataStudio.Snippets.editor.type("SELECT 1");

View File

@@ -2284,6 +2284,97 @@ LIMIT
},
);
it(
"should navigate to the common library when clicking 'common' in an import statement",
{ tags: ["@python"] },
() => {
visitTransformListPage();
cy.button("Create a transform").click();
H.popover().findByText("Python script").click();
cy.get(".cm-clickable-token").should("be.visible").click();
H.modal().button("Discard changes").click();
cy.url().should("include", "/data-studio/transforms/library/common.py");
cy.findByTestId("python-library-header").should("be.visible");
},
);
it(
"should open the common library in a new tab when cmd-clicking 'common' in an import statement",
{ tags: ["@python"] },
() => {
visitTransformListPage();
cy.window().then((win) => {
cy.stub(win, "open").as("windowOpen");
});
cy.button("Create a transform").click();
H.popover().findByText("Python script").click();
cy.get(".cm-clickable-token")
.should("be.visible")
.click({ metaKey: true });
cy.get("@windowOpen").should(
"have.been.calledWithMatch",
"/data-studio/transforms/library/common.py",
);
},
);
it(
"should be able to run a transform with default import common even without custom library code",
{ tags: ["@python"] },
() => {
setPythonRunnerSettings();
visitTransformListPage();
cy.button("Create a transform").click();
H.popover().findByText("Python script").click();
cy.log("import common should be included by default");
H.PythonEditor.value().should("contain", "import common");
cy.log(
"write a transform that imports common but does not use it - should still run",
);
H.PythonEditor.clear().type(
dedent`
import common
import pandas as pd
def transform():
return pd.DataFrame([{"result": 42}])
`,
{ allowFastSet: true },
);
cy.findByTestId("python-data-picker")
.findByText("Select a table…")
.click();
H.entityPickerModal().within(() => {
cy.findByText("Schema a").click();
cy.findByText("Animals").click();
});
getQueryEditor().button("Save").click();
H.modal().within(() => {
cy.findByLabelText("Name").clear().type("Default common transform");
cy.findByLabelText("Table name").clear().type("default_common");
cy.button("Save").click();
});
H.DataStudio.Transforms.runTab().click();
runTransformAndWaitForSuccess();
H.DataStudio.Transforms.settingsTab().click();
getTableLink().click();
H.queryBuilderHeader()
.findByText("Default Common")
.should("be.visible");
H.assertQueryBuilderRowCount(1);
cy.findByTestId("scalar-value").should("have.text", "42");
},
);
function visitCommonLibrary(path = "common.py") {
cy.visit(`/data-studio/transforms/library/${path}`);
}
@@ -2298,7 +2389,7 @@ LIMIT
cy.log("create a collection from the transforms list");
visitTransformListPage();
cy.button("Create a transform").click();
H.popover().findByText("New collection").click();
H.popover().findByText("Transform folder").click();
H.modal().within(() => {
cy.findByLabelText("Name").type("Marketing Transforms");
@@ -2311,7 +2402,7 @@ LIMIT
cy.log("create a nested collection");
cy.button("Create a transform").click();
H.popover().findByText("New collection").click();
H.popover().findByText("Transform folder").click();
H.modal().within(() => {
cy.findByLabelText("Name").type("Q4 Reports");
@@ -2738,6 +2829,85 @@ LIMIT
.should("be.visible");
});
});
describe("read-only remote sync", () => {
beforeEach(() => {
cy.log("create a transform");
createSqlTransform({
sourceQuery: `SELECT * FROM "${TARGET_SCHEMA}"."${SOURCE_TABLE}"`,
});
cy.log("set up remote sync");
H.setupGitSync();
H.configureGit("read-only");
});
it("should make the transform list page read-only", () => {
cy.log("visit transforms page");
visitTransformListPage();
cy.log("'Create a transform' menu button is not displayed");
cy.button("Create a transform").should("not.exist");
cy.log("clicking Python library navigates to the library editor");
getTransformsList().findByText("Python library").click();
cy.log("python library editor is read-only");
cy.url().should("include", "/data-studio/transforms/library/common.py");
cy.findByRole("alert")
.contains(/The Python library is not editable/)
.should("be.visible");
H.DataStudio.PythonLibrary.editor().within(() => {
cy.findByRole("textbox").should(
"have.attr",
"contenteditable",
"false",
);
cy.findByRole("textbox").should("have.attr", "aria-readonly", "true");
});
});
it("should not allow editing a transform", () => {
cy.log("visit transform");
cy.visit("/data-studio/transforms/1");
cy.log("'edit definition' button is not displayed");
H.DataStudio.Transforms.editDefinition().should("not.exist");
cy.log("visit the Run tab");
H.DataStudio.Transforms.runTab().click();
cy.log("schedule tags are not editable");
cy.findByLabelText("Tags").should("be.visible");
cy.findByLabelText("Tags").should("be.disabled");
cy.log("visit the Settings tab");
H.DataStudio.Transforms.settingsTab().click();
cy.log("'Change target' button is not displayed");
cy.findByRole("button", { name: /Change target/ }).should("not.exist");
cy.log("'Only process new and changed data' switch is not displayed");
cy.findByRole("switch", {
name: /Only process new and changed data/,
}).should("be.disabled");
cy.log("visiting edit mode url directly redirects to view-only mode");
cy.visit("/data-studio/transforms/1/edit");
cy.url().should("not.include", "/edit");
H.DataStudio.Transforms.header()
.findByRole("img", { name: "ellipsis icon" })
.click();
cy.log("ellipsis menu does not have move or delete options");
cy.findByRole("menu").within(() => {
cy.findByRole("menuitem", { name: /History/ }).should("be.visible");
cy.findByRole("menuitem", { name: /Move/ }).should("not.exist");
cy.findByRole("menuitem", { name: /Delete/ }).should("not.exist");
});
});
});
});
describe("scenarios > admin > transforms > databases without :schemas", () => {

View File

@@ -90,7 +90,7 @@ describe("scenarios > dependencies > broken list", () => {
describe("analysis", () => {
it("should show broken dependencies", () => {
H.DataStudio.Tasks.visitBrokenEntities();
H.DependencyDiagnostics.visitBrokenDependencies();
checkList({
visibleEntities: BROKEN_DEPENDENCIES,
hiddenEntities: BROKEN_DEPENDENTS,
@@ -100,10 +100,10 @@ describe("scenarios > dependencies > broken list", () => {
describe("sidebar", () => {
it("should show broken dependents", () => {
H.DataStudio.Tasks.visitBrokenEntities();
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("table dependents");
H.DataStudio.Tasks.list().findByText(TABLE_DISPLAY_NAME).click();
H.DependencyDiagnostics.list().findByText(TABLE_DISPLAY_NAME).click();
checkSidebar({
entityName: TABLE_DISPLAY_NAME,
transformName: TABLE_TRANSFORM,
@@ -112,7 +112,7 @@ describe("scenarios > dependencies > broken list", () => {
});
cy.log("question dependents");
H.DataStudio.Tasks.list().findByText(TABLE_BASED_QUESTION).click();
H.DependencyDiagnostics.list().findByText(TABLE_BASED_QUESTION).click();
checkSidebar({
entityName: TABLE_BASED_QUESTION,
missingColumns: ["PRICE"],
@@ -120,7 +120,7 @@ describe("scenarios > dependencies > broken list", () => {
});
cy.log("model dependents");
H.DataStudio.Tasks.list().findByText(TABLE_BASED_MODEL).click();
H.DependencyDiagnostics.list().findByText(TABLE_BASED_MODEL).click();
checkSidebar({
entityName: TABLE_BASED_MODEL,
missingColumns: ["AMOUNT"],
@@ -131,8 +131,8 @@ describe("scenarios > dependencies > broken list", () => {
describe("search", () => {
it("should search for entities", () => {
H.DataStudio.Tasks.visitBrokenEntities();
H.DataStudio.Tasks.searchInput().type(TABLE_DISPLAY_NAME);
H.DependencyDiagnostics.visitBrokenDependencies();
H.DependencyDiagnostics.searchInput().type(TABLE_DISPLAY_NAME);
checkList({
visibleEntities: [TABLE_DISPLAY_NAME],
hiddenEntities: [TABLE_BASED_QUESTION, TABLE_BASED_MODEL],
@@ -142,8 +142,8 @@ describe("scenarios > dependencies > broken list", () => {
describe("filtering", () => {
it("should filter entities by type", () => {
H.DataStudio.Tasks.visitBrokenEntities();
H.DataStudio.Tasks.filterButton().click();
H.DependencyDiagnostics.visitBrokenDependencies();
H.DependencyDiagnostics.filterButton().click();
H.popover().within(() => {
cy.findByText("Table").click();
cy.findByText("Question").click();
@@ -188,8 +188,8 @@ describe("scenarios > dependencies > broken list", () => {
});
it("should filter entities by location", () => {
H.DataStudio.Tasks.visitBrokenEntities();
H.DataStudio.Tasks.filterButton().click();
H.DependencyDiagnostics.visitBrokenDependencies();
H.DependencyDiagnostics.filterButton().click();
H.popover().within(() => {
cy.findByText("Include items in personal collections").click();
});
@@ -205,7 +205,7 @@ describe("scenarios > dependencies > broken list", () => {
describe("sorting", () => {
it("should sort by name", () => {
H.DataStudio.Tasks.visitBrokenEntities();
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("sorted by name by default");
checkListSorting({
@@ -213,39 +213,39 @@ describe("scenarios > dependencies > broken list", () => {
});
cy.log("sorted by name ascending");
H.DataStudio.Tasks.list().findByText("Dependency").click();
H.DependencyDiagnostics.list().findByText("Dependency").click();
checkListSorting({
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_NAME,
});
cy.log("sorted by name descending");
H.DataStudio.Tasks.list().findByText("Dependency").click();
H.DependencyDiagnostics.list().findByText("Dependency").click();
checkListSorting({
visibleEntities: [...BROKEN_DEPENDENCIES_SORTED_BY_NAME].reverse(),
});
});
it("should sort by location", () => {
H.DataStudio.Tasks.visitBrokenEntities();
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("sorted by location ascending");
H.DataStudio.Tasks.list().findByText("Location").click();
H.DependencyDiagnostics.list().findByText("Location").click();
checkListSorting({
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_LOCATION,
});
cy.log("sorted by location descending");
H.DataStudio.Tasks.list().findByText("Location").click();
H.DependencyDiagnostics.list().findByText("Location").click();
checkListSorting({
visibleEntities: [...BROKEN_DEPENDENCIES_SORTED_BY_LOCATION].reverse(),
});
});
it("should sort by dependents errors", () => {
H.DataStudio.Tasks.visitBrokenEntities();
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("sorted by dependents errors ascending");
H.DataStudio.Tasks.list().findByText("Problems").click();
H.DependencyDiagnostics.list().findByText("Problems").click();
checkListSorting({
visibleEntities: [
...BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS,
@@ -253,17 +253,17 @@ describe("scenarios > dependencies > broken list", () => {
});
cy.log("sorted by dependents errors descending");
H.DataStudio.Tasks.list().findByText("Problems").click();
H.DependencyDiagnostics.list().findByText("Problems").click();
checkListSorting({
visibleEntities: BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS,
});
});
it("should sort by dependents with errors", () => {
H.DataStudio.Tasks.visitBrokenEntities();
H.DependencyDiagnostics.visitBrokenDependencies();
cy.log("sorted by dependents with errors ascending");
H.DataStudio.Tasks.list().findByText("Broken dependents").click();
H.DependencyDiagnostics.list().findByText("Broken dependents").click();
checkListSorting({
visibleEntities: [
...BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS,
@@ -271,7 +271,7 @@ describe("scenarios > dependencies > broken list", () => {
});
cy.log("sorted by dependents with errors descending");
H.DataStudio.Tasks.list().findByText("Broken dependents").click();
H.DependencyDiagnostics.list().findByText("Broken dependents").click();
checkListSorting({
visibleEntities: BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS,
});
@@ -318,6 +318,7 @@ function createTransform() {
);
cy.get<TransformId>("@transformId").then((transformId) => {
H.runTransform(transformId);
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TABLE_NAME });
H.waitForTransformRuns(
(runs) =>
runs.length === 1 && runs.every((run) => run.status === "succeeded"),
@@ -483,7 +484,7 @@ function checkList({
visibleEntities?: string[];
hiddenEntities?: string[];
}) {
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.list().within(() => {
visibleEntities.forEach((name) => {
cy.findByText(name).should("exist");
});
@@ -504,24 +505,24 @@ function checkSidebar({
missingColumns?: string[];
brokenDependents?: string[];
}) {
H.DataStudio.Tasks.sidebar().within(() => {
H.DataStudio.Tasks.Sidebar.header()
H.DependencyDiagnostics.sidebar().within(() => {
H.DependencyDiagnostics.Sidebar.header()
.findByText(entityName)
.should("be.visible");
if (transformName) {
H.DataStudio.Tasks.Sidebar.transformInfo()
H.DependencyDiagnostics.Sidebar.transformSection()
.findByText(transformName)
.should("exist");
}
if (missingColumns) {
H.DataStudio.Tasks.Sidebar.missingColumnsInfo().within(() => {
H.DependencyDiagnostics.Sidebar.missingColumnsSection().within(() => {
missingColumns.forEach((column) => {
cy.findByText(column).should("exist");
});
});
}
if (brokenDependents) {
H.DataStudio.Tasks.Sidebar.brokenDependentsInfo().within(() => {
H.DependencyDiagnostics.Sidebar.brokenDependentsSection().within(() => {
brokenDependents.forEach((dependent) => {
cy.findByText(dependent).should("exist");
});
@@ -531,7 +532,7 @@ function checkSidebar({
}
function checkListSorting({ visibleEntities }: { visibleEntities: string[] }) {
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.list().within(() => {
visibleEntities.forEach((name, index) => {
cy.findByText(name)
.parents("[data-index]")

View File

@@ -104,8 +104,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("analysis", () => {
it("should show unreferenced entities", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.list().within(() => {
ENTITY_NAMES.forEach((name) => {
cy.findByText(name).should("be.visible");
});
@@ -114,8 +114,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
it("should not show referenced entities", () => {
createEntities({ withReferences: true });
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.list().within(() => {
ENTITY_NAMES.forEach((name) => {
cy.findByText(name).should("not.exist");
});
@@ -126,8 +126,10 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("search", () => {
it("should search for entities", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.searchInput().type(MODEL_FOR_QUESTION_DATA_SOURCE);
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.searchInput().type(
MODEL_FOR_QUESTION_DATA_SOURCE,
);
checkList({
visibleEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
hiddenEntities: [MODEL_FOR_MODEL_DATA_SOURCE],
@@ -136,8 +138,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
it("should search for entities with type filters", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.searchInput().type("tag");
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.searchInput().type("tag");
checkList({
visibleEntities: [
MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
@@ -146,7 +148,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
hiddenEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
});
H.DataStudio.Tasks.filterButton().click();
H.DependencyDiagnostics.filterButton().click();
H.popover().findByText("Snippet").click();
checkList({
visibleEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG],
@@ -158,10 +160,10 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("filters", () => {
it("should filter entities by type", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DependencyDiagnostics.visitUnreferencedEntities();
checkList({ visibleEntities: ENTITY_NAMES });
H.DataStudio.Tasks.filterButton().click();
H.DependencyDiagnostics.filterButton().click();
H.popover().findByText("Model").click();
checkList({ hiddenEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG] });
@@ -188,7 +190,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
it("should filter by location", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DependencyDiagnostics.visitUnreferencedEntities();
checkList({
visibleEntities: [
MODEL_FOR_MODEL_DATA_SOURCE,
@@ -197,7 +199,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
],
});
H.DataStudio.Tasks.filterButton().click();
H.DependencyDiagnostics.filterButton().click();
H.popover().findByText("Include items in personal collections").click();
checkList({
visibleEntities: [
@@ -221,8 +223,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("sorting", () => {
it("should sort by name", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.searchInput().type("Model for");
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.searchInput().type("Model for");
cy.log("sorted by name by default");
checkListSorting({
@@ -230,13 +232,13 @@ describe("scenarios > dependencies > unreferenced list", () => {
});
cy.log("sorted by name ascending");
H.DataStudio.Tasks.list().findByText("Name").click();
H.DependencyDiagnostics.list().findByText("Name").click();
checkListSorting({
visibleEntities: MODELS_SORTED_BY_NAME,
});
cy.log("sorted by name descending");
H.DataStudio.Tasks.list().findByText("Name").click();
H.DependencyDiagnostics.list().findByText("Name").click();
checkListSorting({
visibleEntities: [...MODELS_SORTED_BY_NAME].reverse(),
});
@@ -244,17 +246,17 @@ describe("scenarios > dependencies > unreferenced list", () => {
it("should sort by location", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DataStudio.Tasks.searchInput().type("Model for");
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DependencyDiagnostics.searchInput().type("Model for");
cy.log("sorted by location ascending");
H.DataStudio.Tasks.list().findByText("Location").click();
H.DependencyDiagnostics.list().findByText("Location").click();
checkListSorting({
visibleEntities: MODELS_SORTED_BY_LOCATION,
});
cy.log("sorted by location descending");
H.DataStudio.Tasks.list().findByText("Location").click();
H.DependencyDiagnostics.list().findByText("Location").click();
checkListSorting({
visibleEntities: [...MODELS_SORTED_BY_LOCATION].reverse(),
});
@@ -264,9 +266,9 @@ describe("scenarios > dependencies > unreferenced list", () => {
describe("sidebar", () => {
it("should show the sidebar for supported entities", () => {
createEntities();
H.DataStudio.Tasks.visitUnreferencedEntities();
H.DependencyDiagnostics.visitUnreferencedEntities();
H.DataStudio.Tasks.list()
H.DependencyDiagnostics.list()
.findByText(MODEL_FOR_QUESTION_DATA_SOURCE)
.click();
checkSidebar({
@@ -275,21 +277,25 @@ describe("scenarios > dependencies > unreferenced list", () => {
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list().findByText(MODEL_FOR_MODEL_DATA_SOURCE).click();
H.DependencyDiagnostics.list()
.findByText(MODEL_FOR_MODEL_DATA_SOURCE)
.click();
checkSidebar({
entityName: MODEL_FOR_MODEL_DATA_SOURCE,
locationName: "First collection",
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list().findByText(SEGMENT_FOR_QUESTION_FILTER).click();
H.DependencyDiagnostics.list()
.findByText(SEGMENT_FOR_QUESTION_FILTER)
.click();
checkSidebar({
entityName: SEGMENT_FOR_QUESTION_FILTER,
locationName: "Orders",
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list()
H.DependencyDiagnostics.list()
.findByText(METRIC_FOR_QUESTION_AGGREGATION)
.click();
checkSidebar({
@@ -298,7 +304,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list()
H.DependencyDiagnostics.list()
.findByText(METRIC_FOR_MODEL_AGGREGATION)
.click();
checkSidebar({
@@ -307,7 +313,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
creatorName: "Bobby Tables",
});
H.DataStudio.Tasks.list()
H.DependencyDiagnostics.list()
.findByText(SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG)
.click();
checkSidebar({
@@ -986,7 +992,7 @@ function checkList({
visibleEntities?: string[];
hiddenEntities?: string[];
}) {
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.list().within(() => {
visibleEntities.forEach((name) => {
cy.findByText(name).should("be.visible");
});
@@ -997,7 +1003,7 @@ function checkList({
}
function checkListSorting({ visibleEntities }: { visibleEntities: string[] }) {
H.DataStudio.Tasks.list().within(() => {
H.DependencyDiagnostics.list().within(() => {
visibleEntities.forEach((name, index) => {
cy.findByText(name)
.parents("[data-index]")
@@ -1015,17 +1021,17 @@ function checkSidebar({
locationName?: string;
creatorName?: string;
}) {
H.DataStudio.Tasks.sidebar().within(() => {
H.DataStudio.Tasks.Sidebar.header()
H.DependencyDiagnostics.sidebar().within(() => {
H.DependencyDiagnostics.Sidebar.header()
.findByText(entityName)
.should("be.visible");
if (locationName) {
H.DataStudio.Tasks.Sidebar.locationInfo()
H.DependencyDiagnostics.Sidebar.locationSection()
.findByText(locationName)
.should("be.visible");
}
if (creatorName) {
H.DataStudio.Tasks.Sidebar.creationInfo().should(
H.DependencyDiagnostics.Sidebar.creationSection().should(
"contain.text",
creatorName,
);

View File

@@ -1236,6 +1236,155 @@ describe("documents", () => {
});
});
describe("anchor links", () => {
// Helper to create filler paragraphs for scroll tests
const createFillerParagraphs = (count: number, startIndex: number) =>
Array.from({ length: count }, (_, i) => ({
type: "paragraph",
attrs: { _id: `filler-paragraph-${startIndex + i}` },
content: [
{
type: "text",
text: `This is filler paragraph ${startIndex + i} to make the document long enough to require scrolling. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
},
],
}));
beforeEach(() => {
H.createDocument({
name: "Anchor Test Document",
document: {
content: [
{
type: "heading",
attrs: { level: 1, _id: "heading-block-1" },
content: [{ type: "text", text: "First Heading" }],
},
{
type: "paragraph",
attrs: { _id: "paragraph-block-1" },
content: [{ type: "text", text: "Some content here" }],
},
// Add filler content to ensure scrolling is needed
...createFillerParagraphs(15, 1),
{
type: "heading",
attrs: { level: 2, _id: "heading-block-2" },
content: [{ type: "text", text: "Second Heading" }],
},
{
type: "paragraph",
attrs: { _id: "paragraph-block-2" },
content: [{ type: "text", text: "More content here" }],
},
// More filler to push blockquote down
...createFillerParagraphs(10, 16),
{
type: "blockquote",
attrs: { _id: "blockquote-block-1" },
content: [
{
type: "paragraph",
attrs: { _id: "quote-paragraph" },
content: [{ type: "text", text: "A nice quote" }],
},
],
},
],
type: "doc",
},
collection_id: null,
alias: "document",
idAlias: "documentId",
});
});
it("should show anchor link icon on left side when hovering over a heading", () => {
H.visitDocument("@documentId");
H.documentContent()
.findByRole("heading", { name: "First Heading" })
.realHover();
// Filter to visible one since all blocks have hidden buttons
cy.get('[data-testid="anchor-link-menu"]')
.filter(":visible")
.first()
.findByRole("button", { name: /copy link/i })
.should("be.visible");
});
it("should copy anchor URL to clipboard when clicking anchor link", () => {
H.visitDocument("@documentId");
cy.wrap(
Cypress.automation("remote:debugger:protocol", {
command: "Browser.grantPermissions",
params: {
permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"],
origin: window.location.origin,
},
}),
);
H.documentContent()
.findByRole("heading", { name: "First Heading" })
.realHover();
// Filter to visible one since all blocks have hidden buttons
cy.get('[data-testid="anchor-link-menu"]')
.filter(":visible")
.first()
.findByRole("button", { name: /copy link/i })
.click();
cy.get("body").findByText("Copied!").should("be.visible");
cy.window().then((win) => {
win.navigator.clipboard.readText().then((text) => {
expect(text).to.include("/document/");
expect(text).to.include("#heading-block-1");
});
});
});
it("should scroll to the correct block when navigating with anchor hash", () => {
cy.get("@documentId").then((documentId) => {
cy.visit(`/document/${documentId}#heading-block-2`);
H.documentContent()
.findByRole("heading", { name: "Second Heading" })
.should("be.visible");
H.documentContent()
.findByRole("heading", { name: "First Heading" })
.should("not.be.visible");
});
});
it("should still show comments menu on right side (regression check)", () => {
H.visitDocument("@documentId");
H.documentContent()
.findByRole("heading", { name: "First Heading" })
.realHover();
// Filter to visible one since all blocks have hidden menus
cy.get('[data-testid="anchor-link-menu"]')
.filter(":visible")
.first()
.findByRole("button", { name: /copy link/i })
.should("be.visible");
// Comments button uses ForwardRefLink, so it's a link role not button
cy.get('[data-testid="comments-menu"]')
.filter(":visible")
.first()
.findByRole("link", { name: /comments/i })
.should("be.visible");
});
});
describe("error handling", () => {
it("should display an error toast when creating a new document fails", () => {
// setup

View File

@@ -54,5 +54,15 @@ describe(
);
});
});
it("should not show CORS setting", () => {
cy.visit("/admin/embedding");
cy.findByTestId("admin-layout-content").within(() => {
cy.findByTestId("embedding-app-origins-sdk-setting").should(
"not.exist",
);
});
});
},
);

View File

@@ -46,4 +46,12 @@ describe("scenarios > embedding > admin settings > starter", () => {
cy.icon("gem").should("be.visible");
});
});
it("should not show CORS setting", () => {
cy.visit("/admin/embedding");
cy.findByTestId("admin-layout-content").within(() => {
cy.findByTestId("embedding-app-origins-sdk-setting").should("not.exist");
});
});
});

View File

@@ -60,7 +60,7 @@ const assertEditorDiffState = (opts: { exists: boolean }) => {
.should(should);
};
const sendCodgeBotMessage = (message: string) => {
const sendCodgenBotMessage = (message: string) => {
H.sendMetabotMessage(message);
cy.wait("@metabotAgent").its("request.body").should("deep.include", {
profile_id: "transforms_codegen",
@@ -103,7 +103,7 @@ describe(
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 1"),
),
});
sendCodgeBotMessage(
sendCodgenBotMessage(
"Create a new native SQL transform that gives me the number 1",
);
assertSuggestionInSidebar({ newSourcePartial: "SELECT 1" });
@@ -122,7 +122,7 @@ describe(
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 2"),
),
});
sendCodgeBotMessage("Make this give me the number 2 instead");
sendCodgenBotMessage("Make this give me the number 2 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 1",
newSourcePartial: "SELECT 2",
@@ -144,7 +144,7 @@ describe(
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 4"),
),
});
sendCodgeBotMessage("Make this give me the number 4 instead");
sendCodgenBotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 3",
newSourcePartial: "SELECT 4",
@@ -173,7 +173,7 @@ describe(
),
),
});
sendCodgeBotMessage(
sendCodgenBotMessage(
"Create a new native python transform that gives me the number 1",
);
assertSuggestionInSidebar({
@@ -199,7 +199,7 @@ describe(
),
),
});
sendCodgeBotMessage("Make this give me the number 2 instead");
sendCodgenBotMessage("Make this give me the number 2 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [1]})",
newSourcePartial: "pd.DataFrame({'value': [2]})",
@@ -233,7 +233,7 @@ describe(
),
),
});
sendCodgeBotMessage("Make this give me the number 4 instead");
sendCodgenBotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [3]})",
newSourcePartial: "pd.DataFrame({'value': [4]})",
@@ -281,7 +281,7 @@ describe(
),
),
});
sendCodgeBotMessage(
sendCodgenBotMessage(
"Create a transform that queries the Test Model",
);
assertSuggestionInSidebar({ newSourcePartial: "SELECT * FROM" });
@@ -326,7 +326,7 @@ describe(
),
});
});
sendCodgeBotMessage(
sendCodgenBotMessage(
"Update my SQL transform to select 2 instead of 1.",
);
assertSuggestionInSidebar({
@@ -359,7 +359,7 @@ describe(
),
});
});
sendCodgeBotMessage("Make this give me the number 4 instead");
sendCodgenBotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 3",
newSourcePartial: "SELECT 4",
@@ -403,7 +403,7 @@ describe(
),
});
});
sendCodgeBotMessage(
sendCodgenBotMessage(
"Update my SQL transform to select 2 instead of 1.",
);
assertSuggestionInSidebar({
@@ -445,7 +445,7 @@ describe(
),
),
});
sendCodgeBotMessage("Make this give me the number 4 instead");
sendCodgenBotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [3]})",
newSourcePartial: "pd.DataFrame({'value': [4]})",

View File

@@ -54,7 +54,7 @@ describe("scenarios > auth > signin > SSO", () => {
cy.findByLabelText("Password").type("123");
cy.button("Sign in").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.contains("Password: did not match stored password");
cy.contains("did not match stored password");
});
it("should pass `redirect` search params from Google button screen to email/password screen (metabase#16216)", () => {

View File

@@ -1,6 +1,6 @@
const { H } = cy;
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
import type {
@@ -1421,17 +1421,36 @@ describe("issue #47005", () => {
H.restore("postgres-12");
cy.signInAsNormalUser();
H.createQuestion({
H.createCardWithQuery({
name: "Question A",
query: {
"source-table": ORDERS_ID,
databaseId: SAMPLE_DB_ID,
stages: [
{
source: {
type: "table",
id: ORDERS_ID,
},
},
],
},
}).then(({ body: question }) => {
H.createQuestion(
H.createCardWithQuery(
{
name: "Question B",
metadata: {
cardIds: [question.id],
},
query: {
"source-table": "card__" + question.id,
databaseId: SAMPLE_DB_ID,
stages: [
{
source: {
type: "card",
id: question.id,
},
},
],
},
},
{ visitQuestion: true },
@@ -1454,13 +1473,30 @@ describe("issue 66210", () => {
H.restore();
cy.signInAsAdmin();
H.createQuestion({
H.createCardWithQuery({
name: METRIC_NAME,
query: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
},
type: "metric",
query: {
databaseId: SAMPLE_DB_ID,
stages: [
{
source: {
type: "table",
id: ORDERS_ID,
},
aggregations: [
{
name: "Count",
value: {
type: "operator",
operator: "count",
args: [],
},
},
],
},
],
},
});
cy.visit("/");
@@ -1494,3 +1530,85 @@ describe("issue #67903", () => {
cy.findByTestId("table-header").should("not.be.visible");
});
});
describe("issue 68574", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
const questionDetails: NativeQuestionDetails = {
name: "Question 1",
native: {
query: "SELECT * FROM ORDERS WHERE CREATED_AT > {{ start }}",
"template-tags": {
start: {
type: "date",
name: "start",
"display-name": "Start",
id: "1",
},
},
},
parameters: [
createMockParameter({
id: "1",
slug: "start",
required: true,
name: "Start",
type: "date/single",
target: ["variable", ["template-tag", "start"]],
}),
],
};
H.createNativeQuestion(questionDetails, { wrapId: true });
});
it("should be possible to run a query for a empty required parameter without a default value (metabase#68574)", () => {
updateFormattingSettings({
date_style: "D MMMM, YYYY",
date_abbreviate: false,
});
visitQuestion("2024-01-01");
assertParameterFormat("1 January, 2024");
cy.log("change the date format");
updateFormattingSettings({
date_style: "dddd, MMMM D, YYYY",
date_abbreviate: false,
});
visitQuestion("2024-01-01");
assertParameterFormat("Monday, January 1, 2024");
cy.log("enable date abbreviation");
updateFormattingSettings({
date_style: "dddd, MMMM D, YYYY",
date_abbreviate: true,
});
visitQuestion("2024-01-01");
assertParameterFormat("Mon, Jan 1, 2024");
cy.log("even when the setting is unset, it should render a valid format");
updateFormattingSettings(undefined);
visitQuestion("2024-01-01");
assertParameterFormat("January 1, 2024");
});
function updateFormattingSettings(settings: any) {
H.updateSetting("custom-formatting", {
"type/Temporal": settings,
});
}
function visitQuestion(value: string) {
cy.get("@questionId").then((id) => {
cy.visit(`/question/${id}?start=${value}`);
});
}
function assertParameterFormat(value: string) {
cy.findByTestId("parameter-value-widget-target")
.should("be.visible")
.should("contain.text", value);
}
});

View File

@@ -696,6 +696,99 @@ describe("scenarios > visualizations > table > dashboards context", () => {
});
});
it("should update row heights correctly when sorting with text wrapping enabled (metabase#61164)", () => {
// This test verifies that when sorting changes, row heights are recalculated
// based on the new row content at each position (not the old cached heights)
H.createQuestionAndDashboard({
questionDetails: {
name: "reviews for sorting test",
type: "model",
query: {
"source-table": SAMPLE_DATABASE.REVIEWS_ID,
limit: 10,
},
visualization_settings: {
"table.column_widths": [200, 100, 100, 100, 100],
column_settings: {
'["name","BODY"]': {
text_wrapping: true,
},
},
"table.columns": [
{
name: "BODY",
enabled: true,
},
{
name: "RATING",
enabled: true,
},
{
name: "ID",
enabled: true,
},
{
name: "PRODUCT_ID",
enabled: true,
},
{
name: "REVIEWER",
enabled: true,
},
],
},
},
dashboardDetails: {
name: "Dashboard",
},
cardDetails: {
size_x: 24,
size_y: 12,
},
}).then(({ body: { dashboard_id } }) => {
H.visitDashboard(dashboard_id);
// Wait for table to render, then sort and verify rows don't overlap
H.tableInteractive()
.find("[data-index=0]")
.should("exist")
.then(() => {
H.tableHeaderClick("Rating");
// Verify rows don't overlap by checking their bounding rects
H.tableInteractive()
.find("[role=row]")
.then(($rows) => {
const rects = $rows
.toArray()
.map((row) => row.getBoundingClientRect())
.sort((a, b) => a.top - b.top);
// Each row's top should equal the previous row's bottom (no overlap)
for (let i = 1; i < rects.length; i++) {
expect(rects[i].top).to.equal(rects[i - 1].bottom);
}
});
// Sort again (descending) to verify heights update on subsequent sorts
H.tableHeaderClick("Rating");
H.tableInteractive()
.find("[role=row]")
.then(($rows) => {
const rects = $rows
.toArray()
.map((row) => row.getBoundingClientRect())
.sort((a, b) => a.top - b.top);
for (let i = 1; i < rects.length; i++) {
expect(rects[i].top).to.equal(rects[i - 1].bottom);
}
});
});
});
});
it("should support the row index setting", () => {
H.visitDashboard(ORDERS_DASHBOARD_ID);
H.editDashboard();

View File

@@ -65,14 +65,16 @@
"Adds to `user` a set of boolean flags indicating whether or not current user has access to advanced permissions.
This function is meant to be used for GET /api/user/current."
[user]
(let [permissions-set @api/*current-user-permissions-set*
user-id api/*current-user-id*]
(let [permissions-set @api/*current-user-permissions-set*
user-id api/*current-user-id*
can-access-data-model (perms/user-has-any-perms-of-type? user-id :perms/manage-table-metadata)]
(update user :permissions assoc
:can_access_setting (perms/set-has-application-permission-of-type? permissions-set :setting)
:can_access_subscription (perms/set-has-application-permission-of-type? permissions-set :subscription)
:can_access_monitoring (perms/set-has-application-permission-of-type? permissions-set :monitoring)
:can_access_data_model (perms/user-has-any-perms-of-type? user-id :perms/manage-table-metadata)
:can_access_data_model can-access-data-model
:can_access_db_details (perms/user-has-any-perms-of-type? user-id :perms/manage-database)
:is_data_analyst api/*is-data-analyst?*
:is_group_manager api/*is-group-manager?*)))
(defenterprise current-user-has-application-permissions?

View File

@@ -20,6 +20,9 @@
{:name :sso-saml
:available (premium-features/enable-sso-saml?)
:enabled (sso-settings/saml-enabled)}
{:name :sso-slack
:available (premium-features/enable-sso-slack?)
:enabled (sso-settings/slack-connect-enabled)}
{:name :scim
:available (premium-features/enable-scim?)
:enabled (boolean (scim/scim-enabled))}

View File

@@ -188,7 +188,7 @@
[:entity_type {:optional true} [:maybe :string]]
[:owner_email {:optional true} [:maybe :string]]
[:owner_user_id {:optional true} [:maybe :int]]]]]
(api/check-superuser)
(api/check-data-analyst)
(let [where (table-selectors->filter (select-keys body [:database_ids :schema_ids :table_ids]))
set-ks [:data_authority
:data_source
@@ -214,7 +214,7 @@
[_route-params
_query-params
body :- ::table-selectors]
(api/check-superuser)
(api/check-data-analyst)
(let [fields [:model/Table :id :db_id :name :display_name :schema :is_published]
where (table-selectors->filter (select-keys body [:database_ids :schema_ids :table_ids]))
selected-tables (t2/select fields {:where where :limit 2})
@@ -235,7 +235,7 @@
[_route-params
_query-params
body :- ::table-selectors]
(api/check-superuser)
(api/check-data-analyst)
(let [target-collection (api/let-404 [colls (seq (t2/select :model/Collection
:type collection/library-data-collection-type
{:limit 2}))]
@@ -267,7 +267,7 @@
[_route-params
_query-params
body :- ::table-selectors]
(api/check-superuser)
(api/check-data-analyst)
(let [where (table-selectors->filter (select-keys body [:database_ids :schema_ids :table_ids]))
downstream-ids (all-downstream-table-ids where)
update-where (if (seq downstream-ids)
@@ -299,7 +299,7 @@
[_
_
body :- ::table-selectors]
(api/check-superuser)
(api/check-data-analyst)
(let [tables (t2/select :model/Table {:where (table-selectors->filter body), :order-by [[:id]]})
db-ids (sort (set (map :db_id tables)))]
(doseq [database (t2/select :model/Database :id [:in db-ids])]
@@ -318,7 +318,7 @@
[_
_
body :- ::table-selectors]
(api/check-superuser)
(api/check-data-analyst)
(let [tables (t2/select :model/Table {:where (table-selectors->filter body), :order-by [[:id]]})]
;; same permission skip as the single-table api, see comment in /:id/rescan_values
(doseq [table tables]
@@ -331,7 +331,7 @@
[_
_
body :- ::table-selectors]
(api/check-superuser)
(api/check-data-analyst)
(let [tables (t2/select :model/Table {:where (table-selectors->filter body), :order-by [[:id]]})]
(let [field-ids-to-delete-q {:select [:id]
:from [(t2/table-name :model/Field)]

View File

@@ -1,6 +1,7 @@
(ns metabase-enterprise.dependencies.api
(:require
[clojure.set :as set]
[clojure.string :as str]
[medley.core :as m]
[metabase-enterprise.dependencies.core :as dependencies]
[metabase-enterprise.dependencies.dependency-types :as deps.dependency-types]
@@ -476,14 +477,18 @@
(defn- node-downstream-errors
"Fetches errors caused by the given source entities (what downstream entities they're breaking).
Filters out errors where the analyzed entity is not visible to the current user.
Unlike `node-errors` which fetches errors on an entity, this fetches errors that
the entity is causing in other entities that depend on it."
[nodes-by-type]
(letfn [(errors-by-source-type-and-id [[source-type ids]]
(when (seq ids)
(let [finding-errors (t2/select :model/AnalysisFindingError
:source_entity_type source-type
:source_entity_id [:in ids])]
{:where [:and
[:= :source_entity_type (name source-type)]
[:in :source_entity_id ids]
(visible-entities-filter-clause
:analyzed_entity_type :analyzed_entity_id)]})]
(u/group-by (juxt :source_entity_type :source_entity_id)
identity conj #{} finding-errors))))]
(->> nodes-by-type
@@ -492,6 +497,7 @@
(defn- node-errors
"Fetches and normalizes AnalysisFindingErrors for the given entities.
Filters out errors where the source entity is not visible to the current user.
Returns {[entity-type entity-id] #{error-maps...}}, or nil if none."
[nodes-by-type]
(letfn [(normalize-finding-error
@@ -501,8 +507,13 @@
(errors-by-entity-type-and-id [[type ids]]
(when (seq ids)
(let [finding-errors (t2/select :model/AnalysisFindingError
:analyzed_entity_type type
:analyzed_entity_id [:in ids])]
{:where [:and
[:= :analyzed_entity_type (name type)]
[:in :analyzed_entity_id ids]
[:or
[:= :source_entity_type nil]
(visible-entities-filter-clause
:source_entity_type :source_entity_id)]]})]
(u/group-by (juxt :analyzed_entity_type :analyzed_entity_id)
normalize-finding-error conj #{} finding-errors))))]
(->> nodes-by-type
@@ -587,20 +598,84 @@
{:nodes (expanded-nodes downstream-graph nodes {:include-errors? false})
:edges edges})))
(def ^:private sort-directions
"Valid sort directions for dependency item endpoints."
#{:asc :desc})
(def ^:private dependents-sort-columns
"Valid sort columns for the /graph/dependents endpoint."
#{:name :location :view-count})
(defn- entity-name
"Returns the name string for an entity based on its type. Might return nil."
[entity]
(let [data (:data entity)]
(case (:type entity)
:table (:display_name data)
:sandbox nil
(:name data))))
(defn- entity-location
"Returns the location string for an entity based on its type. Might return nil."
[entity]
(let [data (:data entity)]
(case (:type entity)
:card (or (-> data :dashboard :name)
(-> data :document :name)
(-> data :collection :name))
:table (-> data :db :name)
(:transform :snippet :dashboard :document) (-> data :collection :name)
:sandbox nil
(:segment :measure) (-> data :table :display_name)
nil)))
(defn- string-matches-query?
"Returns true if `s` is a string and its lower-case version contains the string `query`.
`query` is expected to be lower-case."
[s query]
(some-> s u/lower-case-en (str/includes? query)))
(defn- entity-matches-query?
"Returns true if `entity`'s name or location contains the string `query` (case-insensitive)."
[entity query]
(let [q (u/lower-case-en query)]
(or (string-matches-query? (entity-name entity) q)
(string-matches-query? (entity-location entity) q))))
(defn- in-personal-collection?
"Returns true if `entity` is in a personal collection."
[entity]
(get-in entity [:data :collection :is_personal]))
(defn- sort-dependents
"Sort `entities` by `sort-column` in `sort-direction`."
[entities sort-column sort-direction]
(let [key-fn (case sort-column
:name #(some-> (entity-name %) u/lower-case-en)
:location #(some-> (entity-location %) u/lower-case-en)
:view-count #(or (-> % :data :view_count) 0))
comp-fn (cond->> compare
(= sort-direction :desc) (comp -))]
(sort-by key-fn comp-fn entities)))
(def ^:private dependents-args
[:map
[:id ms/PositiveInt]
[:type ::deps.dependency-types/dependency-types]
[:dependent_types {:optional true}
[:id ms/PositiveInt]
[:type ::deps.dependency-types/dependency-types]
[:dependent_types {:optional true}
[:or
::deps.dependency-types/dependency-types
[:sequential ::deps.dependency-types/dependency-types]]]
[:dependent_card_types {:optional true}
[:dependent_card_types {:optional true}
[:or
(ms/enum-decode-keyword lib.schema.metadata/card-types)
[:sequential (ms/enum-decode-keyword lib.schema.metadata/card-types)]]]
[:archived {:optional true} :boolean]
[:broken {:optional true} :boolean]])
[:archived {:optional true} :boolean]
[:broken {:optional true} :boolean]
[:query {:optional true} :string]
[:include_personal_collections {:optional true} :boolean]
[:sort_column {:optional true} (ms/enum-decode-keyword dependents-sort-columns)]
[:sort_direction {:optional true} (ms/enum-decode-keyword sort-directions)]])
(api.macros/defendpoint :get "/graph/dependents" :- [:sequential ::entity]
"Returns a list of dependents for the specified entity.
@@ -615,9 +690,17 @@
- `dependent_card_types`: Card types to filter by when dependent_types includes :card.
Ignored if dependent_types doesn't include :card. Example: ?dependent_card_types=question&dependent_card_types=model
- `archived`: Include entities in archived collections (default: false)
- `broken`: Return only broken entities (default: false)"
- `broken`: Return only broken entities (default: false)
- `query`: Search string to filter results by name or location (case-insensitive)
- `include_personal_collections`: Include items in personal collections (default: false)
- `sort_column`: Column to sort by - name, location, or view-count (default: name)
- `sort_direction`: Sort direction - asc or desc (default: asc)"
[_route-params
{:keys [id type dependent_types dependent_card_types archived broken]} :- dependents-args]
{:keys [id type dependent_types dependent_card_types archived broken
query include_personal_collections sort_column sort_direction]
:or {include_personal_collections false
sort_column :name
sort_direction :asc}} :- dependents-args]
(api/read-check (deps.dependency-types/dependency-type->model type) id)
(lib-be/with-metadata-provider-cache
(let [graph-opts {:include-archived-items (if archived :all :exclude)
@@ -632,14 +715,26 @@
card-types-set (cond
(nil? dependent_card_types) lib.schema.metadata/card-types
(sequential? dependent_card_types) (set dependent_card_types)
:else #{dependent_card_types})]
(->> (expanded-nodes downstream-graph nodes {:include-errors? false})
:else #{dependent_card_types})
dependents-filter
(comp
;; Filter by dependent types and card types
(filter (fn [node]
(and (or (nil? dep-types-set)
(contains? dep-types-set (:type node)))
(or (not= (:type node) :card)
(nil? card-types-set)
(contains? card-types-set (-> node :data :type))))))))))
(contains? card-types-set (-> node :data :type))))))
;; Filter out personal collections unless explicitly included
(if include_personal_collections
identity
(remove in-personal-collection?))
;; Filter by query (sandboxes are excluded since they have no name or location)
(if query
(filter #(entity-matches-query? % query))
identity))]
(-> (into [] dependents-filter (expanded-nodes downstream-graph nodes {:include-errors? false}))
(sort-dependents sort_column sort_direction)))))
(defn- entity-type-config
[entity-type]
@@ -673,20 +768,28 @@
(:segment :measure) :table.display_name)}))
(defn- query-type-join-and-filter
[query-type entity-type]
[query-type entity-type {:keys [include-archived-items]}]
(case query-type
:unreferenced {:join [:dependency [:and
[:= :dependency.to_entity_id :entity.id]
[:= :dependency.to_entity_type (name entity-type)]]]
:filter [:= :dependency.id nil]}
[:= :dependency.to_entity_type (name entity-type)]
(visible-entities-filter-clause
:dependency.from_entity_type
:dependency.from_entity_id
{:include-archived-items include-archived-items})]]
:join-filter [:= :dependency.id nil]}
:broken {:join [:analysis_finding [:and
[:= :analysis_finding.analyzed_entity_id :entity.id]
[:= :analysis_finding.analyzed_entity_type (name entity-type)]]]
:filter [:= :analysis_finding.result false]}
:join-filter [:= :analysis_finding.result false]}
:breaking {:join [:analysis_finding_error [:and
[:= :analysis_finding_error.source_entity_id :entity.id]
[:= :analysis_finding_error.source_entity_type (name entity-type)]]]
:filter [:!= :analysis_finding_error.id nil]}))
[:= :analysis_finding_error.source_entity_type (name entity-type)]
(visible-entities-filter-clause
:analysis_finding_error.analyzed_entity_type
:analysis_finding_error.analyzed_entity_id
{:include-archived-items include-archived-items})]]
:join-filter [:!= :analysis_finding_error.id nil]}))
(defn- location-joins-for-entity
"Returns the set of join keywords needed for location-based operations."
@@ -755,7 +858,9 @@
:from [:analysis_finding_error]
:where [:and
[:= :source_entity_id :entity.id]
[:= :source_entity_type (name entity-type)]]}
[:= :source_entity_type (name entity-type)]
(visible-entities-filter-clause
:analyzed_entity_type :analyzed_entity_id)]}
:sort-joins #{}}
:dependents-with-errors {:sort-column {:select [[[:count [:distinct (if (= :mysql (mdb/db-type))
[:concat :analyzed_entity_id [:inline "-"] :analyzed_entity_type]
@@ -763,7 +868,9 @@
:from [:analysis_finding_error]
:where [:and
[:= :source_entity_id :entity.id]
[:= :source_entity_type (name entity-type)]]}
[:= :source_entity_type (name entity-type)]
(visible-entities-filter-clause
:analyzed_entity_type :analyzed_entity_id)]}
:sort-joins #{}}
{:sort-column name-column
:sort-joins #{}}))
@@ -778,11 +885,14 @@
(:table joins) (conj [:metabase_table :table] [:= :entity.table_id :table.id])))
(defn- dependency-items-query
[{:keys [query-type entity-type sort-column] :as params}]
[{:keys [query-type entity-type sort-column include-archived-items] :as params}]
(let [{:keys [table-name name-column location-column] :as config} (entity-type-config entity-type)
{:keys [join filter]} (query-type-join-and-filter query-type entity-type)
{:keys [join join-filter]} (query-type-join-and-filter query-type entity-type
{:include-archived-items include-archived-items})
{:keys [filters filter-joins]} (build-optional-filters params config)
{:keys [sort-column sort-joins]} (sort-key-cols-and-joins sort-column entity-type name-column location-column)
visible-filter (visible-entities-filter-clause (name entity-type) :entity.id
{:include-archived-items include-archived-items})
all-required-joins (set/union filter-joins sort-joins)
select-clause [[[:inline (name entity-type)] :entity_type]
[:entity.id :entity_id]
@@ -790,16 +900,12 @@
{(if (= query-type :breaking) :select-distinct :select) select-clause
:from [[table-name :entity]]
:left-join (build-left-joins join all-required-joins)
:where (into [:and filter] (keep identity) filters)}))
:where (into [:and join-filter visible-filter] (keep identity) filters)}))
(def ^:private sort-columns
"Valid sort columns for dependency item endpoints."
(def ^:private breaking-items-sort-columns
"Valid sort columns for /graph/broken and /graph/unreferenced endpoints."
#{:name :location :dependents-with-errors :dependents-errors})
(def ^:private sort-directions
"Valid sort directions for dependency item endpoints."
#{:asc :desc})
(def ^:private dependency-items-args
[:map
[:types {:optional true} [:or
@@ -811,7 +917,7 @@
[:query {:optional true} :string]
[:archived {:optional true} :boolean]
[:include_personal_collections {:optional true} :boolean]
[:sort_column {:optional true} (ms/enum-decode-keyword sort-columns)]
[:sort_column {:optional true} (ms/enum-decode-keyword breaking-items-sort-columns)]
[:sort_direction {:optional true} (ms/enum-decode-keyword sort-directions)]])
(def ^:private dependency-items-response

View File

@@ -2,6 +2,7 @@
(:require
[metabase-enterprise.dependencies.native-validation :as deps.native]
[metabase-enterprise.dependencies.schema :as deps.schema]
[metabase-enterprise.transforms.core :as transforms]
[metabase.documents.prose-mirror :as prose-mirror]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
@@ -43,19 +44,31 @@
query-deps
param-card-ids)))
(mu/defn upstream-deps:python-transform :- ::deps.schema/upstream-deps
"Given a Python Transform (in Toucan form), return its upstream dependencies."
[transform]
{:table (->> (get-in transform [:source :source-tables])
vals
(into #{}
(keep #(if (map? %)
(:table_id %)
%))))})
(mu/defn upstream-deps:transform :- ::deps.schema/upstream-deps
"Given a Transform (in Toucan form), return its upstream dependencies."
[{{:keys [query], source-type :type} :source :as transform} :-
[{{:keys [query]} :source :as transform} :-
[:map
[:source [:multi {:dispatch (comp keyword :type)}
[:query
[:map [:query ::lib.schema/query]]]
[:python
[:map]]]]]]
(if (= (keyword source-type) :query)
(upstream-deps:query query)
(do (log/warnf "Don't know how to analyze the deps of Transform %d with source type '%s'" (:id transform) source-type)
{})))
(let [source-type (transforms/transform-type transform)]
(case source-type
:query (upstream-deps:query query)
:python (upstream-deps:python-transform transform)
(do (log/warnf "Don't know how to analyze the deps of Transform %d with source type '%s'" (:id transform) source-type)
{}))))
(mu/defn upstream-deps:snippet :- ::deps.schema/upstream-deps
"Given a native query snippet, return its upstream dependencies in the usual `{entity-type #{1 2 3}}` format."

View File

@@ -4,6 +4,7 @@
[metabase-enterprise.dependencies.dependency-types :as deps.dependency-types]
[metabase-enterprise.dependencies.findings :as deps.findings]
[metabase-enterprise.dependencies.models.dependency :as models.dependency]
[metabase-enterprise.transforms.core :as transforms]
[metabase.events.core :as events]
[metabase.lib-be.core :as lib-be]
[metabase.premium-features.core :as premium-features]
@@ -89,8 +90,8 @@
(derive :event/update-transform ::transform-deps)
;; On *saving* a transform, the upstream deps of its query are computed and saved.
(defn- drop-outdated-target-dep! [{:keys [id source target] :as _transform}]
(let [db-id (some-> source :query :database)
(defn- drop-outdated-target-dep! [{:keys [id target] :as transform}]
(let [db-id (transforms/transform-source-database transform)
downstream-table-ids (t2/select-fn-set :from_entity_id :model/Dependency
:from_entity_type :table
:to_entity_type :transform
@@ -276,20 +277,23 @@
(when (premium-features/has-feature? :dependencies)
(t2/delete! :model/Dependency :from_entity_type :measure :from_entity_id (:id object))))
(defn- check-dependents! [type object recur-through-transforms?]
(defn- check-all-dependents! [type->objects recur-through-transforms?]
(let [graph (if recur-through-transforms?
(models.dependency/graph-dependents)
(models.dependency/filtered-graph-dependents
nil
(fn [type-field _id-field]
[:not= type-field "transform"])))
children-map (models.dependency/transitive-mbql-dependents graph {type [object]})]
children-map (models.dependency/transitive-mbql-dependents graph type->objects)]
(doseq [[type children] children-map
:when (deps.findings/supported-entities type)
instances (partition 50 50 nil children)]
(-> (t2/select (deps.dependency-types/dependency-type->model type) :id [:in instances])
deps.findings/analyze-instances!))))
(defn- check-dependents! [type object recur-through-transforms?]
(check-all-dependents! {type [object]} recur-through-transforms?))
(derive ::check-card-dependents :metabase/event)
(derive :event/card-create ::check-card-dependents)
(derive :event/card-update ::check-card-dependents)
@@ -335,3 +339,49 @@
(when (premium-features/has-feature? :dependencies)
(lib-be/with-metadata-provider-cache
(check-dependents! :transform {:id (:transform-id object)} true))))
(defn- synced-db->direct-dependents-of-changed-tables
"Given the `:db_id` of a freshly synced database, this examines all tables in the DB which were updated, or have
fields which were updated, since the last time any cards depending on them were analyzed.
It is important that this doesn't re-run the analysis for all dependents of every table whose DB got synced -
most of the tables have no change every time.
Returns the set of table IDs which have dependents that need re-analysis, possibly empty."
[db-id]
(t2/select-fn-set :table_id :model/AnalysisFinding
{:select [:field_updates/table_id]
:from [[{:select [[:table/id :table_id]
[:table/updated_at :last_table_update]
[[:max :field/updated_at] :last_field_update]]
:from [[(t2/table-name :model/Table) :table]]
:left-join [[(t2/table-name :model/Field) :field]
[:= :field/table_id :table/id]]
:where [:= :table/db_id db-id]
:group-by [:table/id
:table/updated_at]}
:field_updates]]
:inner-join [[(t2/table-name :model/Dependency) :dep]
[:and
[:= :dep/to_entity_type [:inline "table"]]
[:= :field_updates/table_id :dep/to_entity_id]]
[(t2/table-name :model/AnalysisFinding) :finding]
[:and
[:= :finding/analyzed_entity_type :dep/from_entity_type]
[:= :finding/analyzed_entity_id :dep/from_entity_id]]]
:where [:and
[:!= :finding/analyzed_entity_id nil]
[:or
[:< :finding/analyzed_at :field_updates/last_table_update]
[:< :finding/analyzed_at :field_updates/last_field_update]]]}))
(derive ::sync-completed-on-database :metabase/event)
(derive :event/sync-end ::sync-completed-on-database)
(methodical/defmethod events/publish-event! ::sync-completed-on-database
[_ {db-id :database_id}]
(when (premium-features/has-feature? :dependencies)
(lib-be/with-metadata-provider-cache
(let [changes (synced-db->direct-dependents-of-changed-tables db-id)]
(when (seq changes)
(check-all-dependents! {:table changes} true))))))

View File

@@ -23,8 +23,9 @@
Version history:
- 3: Initial version
- 4: Disable naive sql validation
- 5: Added source entity tracking in analysis_finding_error table"
5)
- 5: Added source entity tracking in analysis_finding_error table
- 6: Removed validate prefix from error_type in analysis_finding_error"
6)
(defn- error->finding-error-row
"Convert an error from lib/find-bad-refs-with-source to a row for analysis_finding_error table.

View File

@@ -14,7 +14,7 @@
(def current-dependency-analysis-version
"Current version of the dependency analysis logic.
This should be incremented when the dependency analysis logic changes."
4)
5)
(methodical/defmethod t2/table-name :model/Dependency [_model] :dependency)
@@ -157,12 +157,16 @@
(filtered-graph key-dependents destination-filter-fn source-filter-fn)))
(defn entities->nodes
"Converts a map of entities `{entity-type [{:id 1, ...} ...]}` into a list of nodes `[[entity-type entity-id]]`."
"Converts a map of entities `{entity-type [{:id 1, ...} ...]}` or entity IDs `{entity-type [1]}` into a list of nodes
`[[entity-type entity-id]]`."
[entities-map]
(for [[entity-type entities] entities-map
entity entities
:when (:id entity)]
[entity-type (:id entity)]))
:let [id (if (number? entity)
entity
(:id entity))]
:when id]
[entity-type id]))
(defn group-nodes
"Groups a list of nodes `[[entity-type entity-id]]` by their type."

View File

@@ -17,11 +17,11 @@
(api.macros/defendpoint :post "/"
"Creates the Library if it doesn't exist. Returns the created collection.
Requires superuser permissions."
Requires data analyst or superuser permissions."
[_route
_query
_body]
(api/check-superuser)
(api/check-data-analyst)
(api/check-400 (not (collections/library-collection)) "Library already exists")
(collections/create-library-collection!))

View File

@@ -7,7 +7,6 @@
(:require
[clojure.string :as str]
[macaw.core :as macaw]
[metabase-enterprise.metabot-v3.query-analyzer.impl :as nqa.impl]
[metabase-enterprise.metabot-v3.query-analyzer.parameter-substitution :as nqa.sub]
[metabase.driver :as driver]
[metabase.driver.util :as driver.u]
@@ -117,7 +116,7 @@
to return tables that are in the query."
[driver query & {:keys [mode] :or {mode :compound-select}}]
(let [db-id (:database query)
macaw-opts (nqa.impl/macaw-options driver)
macaw-opts (driver.u/macaw-options driver)
table-opts (assoc macaw-opts :mode mode)
sql-string (:query (nqa.sub/replace-tags query))
result (macaw/query->tables sql-string table-opts)]
@@ -141,7 +140,7 @@
(defmethod tables-for-native* :sql
[driver query opts]
(if (or (:all-drivers-trusted? opts)
(nqa.impl/trusted-for-table-permissions? driver))
(driver.u/trusted-for-table-permissions? driver))
(tables-via-macaw driver query opts)
{:error :query-analysis.error/driver-not-supported}))

View File

@@ -1,66 +0,0 @@
(ns metabase-enterprise.metabot-v3.query-analyzer.impl
(:require
[metabase.driver :as driver]))
;; NOTE: In the future we should replace [[considered-drivers]] and [[macaw-options]] with (a) driver method(s),
;; but given that the interface with Macaw is still in a state of flux, and how simple the current configuration is,
;; we defer extending the public interface and define the logic locally instead. Macaw itself is not battle tested
;; outside this same narrow range of databases in any case.
(def ^:private considered-drivers
"Since we are unable to ask basic questions of the driver hierarchy outside of that module, we need to explicitly
mention all sub-types. This is probably not a bad thing."
#{:h2 :mysql :postgres :redshift :sqlite :sqlserver})
;; At some point, we may want a way for 3rd party drivers to opt in, but a public API deserves some hammock time.
;; Using a separate list to the above, as we may want this to be more restrictive.
(def trusted-for-table-permissions?
"Do we trust that Macaw will not give us false negatives for tables referenced by a given query?"
#{:h2 :mysql :postgres})
(defn macaw-options
"Generate the options expected by Macaw based on the nature of the given driver."
[driver]
;; If this isn't a driver we've considered, fallback to Macaw's conservative defaults.
(when (contains? considered-drivers driver)
;; According to the SQL-92 specification, non-quoted identifiers should be case-insensitive, and the majority of
;; engines are implemented this way.
;;
;; In practice there are exceptions, notably MySQL and SQL Server, where case sensitivity is a property of the
;; underlying resource referenced by the identifier, and the case-sensitivity does not depend on whether the
;; reference is quoted.
;;
;; For MySQL the case sensitivity of databases and tables depends on both the underlying file system, and a system
;; variable used to initialize the database. For SQL Server it depends on the collation settings of the collection
;; where the corresponding schema element is defined.
;;
;; For MySQL, columns and aliases can never be case-sensitive, and for SQL Server the default collation is case-
;; insensitive too, so it makes sense to just treat all databases as case-insensitive as a whole.
;;
;; In future, Macaw may support discriminating on the identifier type, in which case we could be more precise for
;; these databases. Being 100% correct would require querying system variables and schema configuration however,
;; which is likely a step too far in complexity.
;;
;; Currently, we go with :agnostic, as it is the most relaxed semantics (the case of both the identifiers and the
;; underlying schema is totally ignored, and correspondence is non-deterministic), but Macaw supports more nuanced
;; :lower and :upper configuration values which coerce the query identifiers to a given case then do an exact
;; comparison with the schema.
{:case-insensitive :agnostic
;; For both MySQL and SQL Server, whether identifiers are case-sensitive depends on database configuration only,
;; and quoting has no effect on this, so we disable this option for consistency with `:case-insensitive`.
:quotes-preserve-case? (not (contains? #{:mysql :sqlserver} driver))
:features {:postgres-syntax (isa? driver/hierarchy driver :postgres)
:square-bracket-quotes (= :sqlserver driver)
:unsupported-statements false
:backslash-escape-char true
;; This will slow things down, but until we measure the difference, opt for correctness.
:complex-parsing true}
;; 10 seconds
:timeout 10000
;; There is no plan to be exhaustive yet.
;; Note that while an allowed list would be more conservative, at the time of writing only 2 of the bundled
;; drivers use FINAL as a reserved word, and mentioning them all would be prohibitive.
;; In the future, we will use multimethods to define this explicitly per driver, or even discover it automatically
;; through the JDBC connection, where possible.
:non-reserved-words (vec (remove nil? [(when-not (contains? #{:snowflake :oracle} driver)
:final)]))}))

View File

@@ -51,7 +51,7 @@
to the remote sync source."
[]
(api/check-superuser)
{:is_dirty (remote-sync.object/dirty-global?)})
{:is_dirty (remote-sync.object/dirty?)})
(api.macros/defendpoint :get "/has-remote-changes" :- remote-sync.schema/HasRemoteChangesResponse
"Check if there are new changes on the remote branch that can be pulled.
@@ -80,7 +80,7 @@
(api/check-superuser)
{:dirty (into []
(m/distinct-by (juxt :id :model))
(remote-sync.object/dirty-for-global))})
(remote-sync.object/dirty-objects))})
(api.macros/defendpoint :post "/export" :- remote-sync.schema/ExportResponse
"Export the current state of the Remote Sync collection to a Source.
@@ -138,9 +138,11 @@
[:remote-sync-token {:optional true} [:maybe :string]]
[:remote-sync-type {:optional true} [:maybe [:enum :read-only :read-write]]]
[:remote-sync-branch {:optional true} [:maybe :string]]
[:remote-sync-auto-import {:optional true} [:maybe :boolean]]
[:remote-sync-transforms {:optional true} [:maybe :boolean]]
[:collections {:optional true} [:maybe [:map-of pos-int? :boolean]]]]]
(api/check-superuser)
(api/check-400 (not (and (remote-sync.object/dirty-global?) (= :read-only remote-sync-type)))
(api/check-400 (not (and (remote-sync.object/dirty?) (= :read-only remote-sync-type)))
"There are unsaved changes in the Remote Sync collection which will be overwritten switching to read-only mode.")
;; Check if trying to change collections while in read-only mode
(let [effective-type (or remote-sync-type (settings/remote-sync-type))]

View File

@@ -53,6 +53,18 @@
(not (collections/remote-synced-collection? (or (:collection table)
(:collection_id table))))))
(defenterprise transforms-editable?
"Determines if transforms should be editable.
Returns true if transforms are editable, false otherwise. Transforms are globally
read-only when remote-sync is enabled and remote-sync-type is :read-only.
Always returns true on OSS."
:feature :none
[]
(or (not (settings/remote-sync-enabled))
(= (settings/remote-sync-type) :read-write)))
(mu/defn bulk-set-remote-sync :- :nil
"Sets remote sync to true/false on one or collections in a single transaction. Checks that the remote sync state
afterwards is consistent in terms of dependency rules. Collections are provided as a map of collection-id -> sync state."

View File

@@ -6,17 +6,18 @@
This enables the remote sync system to know which objects have changed
since the last sync operation.
Handlers are defined using the `defmodel-change-handler` macro which:
- Derives event hierarchies for create/update/delete events
- Checks if the model is in a remote-synced collection
- Creates or updates RemoteSyncObject entries with denormalized model details
Event handlers are registered using specs from `metabase-enterprise.remote-sync.spec`.
Each spec defines eligibility checking, field hydration, and other configuration.
Tracked model types:
- Card, Dashboard, Document, NativeQuerySnippet, Timeline, Collection
- Table (when published in a remote-synced collection)
- Field, Segment (when belonging to a published table in a remote-synced collection)"
- Field, Segment (when belonging to a published table in a remote-synced collection)
- Transform, TransformTag, transforms-namespace Collections (when remote-sync-transforms setting is enabled)"
(:require
[java-time.api :as t]
[metabase-enterprise.remote-sync.settings :as settings]
[metabase-enterprise.remote-sync.spec :as spec]
[metabase.collections.core :as collections]
[metabase.events.core :as events]
[metabase.util.log :as log]
@@ -25,13 +26,6 @@
;; Helper functions for model change tracking
(defn- model-in-remote-synced-collection?
"Checks if a model (card, dashboard, document) is in a remote-synced collection. Takes a model instance with a
collection_id field. Returns true if the model is in a remote-synced collection, false otherwise."
[{:keys [collection_id]}]
(boolean
(collections/remote-synced-collection? collection_id)))
(defn- create-or-update-remote-sync-object-entry!
"Creates or updates a remote sync object entry for a model change.
@@ -79,92 +73,100 @@
:model_table_id (:table_id model-details)
:model_table_name (:table_name model-details)})))))
;; Model change tracking event handlers
;;; ----------------------------------------- Spec-based Event Handling ------------------------------------------------
(defmacro ^:private defmodel-change-handler
"Defines event derivations and handler for a standard model change event.
(defn- create-or-update-sync-object-from-spec!
"Creates or updates a RemoteSyncObject entry using a spec for field hydration.
This is the spec-based version of create-or-update-remote-sync-object-entry!."
[model-spec model-id status]
(let [model-type (:model-type model-spec)
existing (t2/select-one :model/RemoteSyncObject :model_type model-type :model_id model-id)]
(cond
;; No existing entry - create new one
(not existing)
(let [model-details (spec/hydrate-model-details model-spec model-id)
fields (spec/build-sync-object-fields model-spec model-details)]
(t2/insert! :model/RemoteSyncObject
(merge {:model_type model-type
:model_id model-id
:status status
:status_changed_at (t/offset-date-time)}
fields)))
Usage:
(defmodel-change-handler card
{:model-type \"Card\"
:event-prefix :event/card
:log-name \"card\"
:hydrate-details (fn [id] (t2/select-one [:model/Card :name :collection_id :display] :id id))})
;; Created then removed/deleted before sync - delete the entry
(and (= "create" (:status existing)) (contains? #{"removed" "delete"} status))
(t2/delete! :model/RemoteSyncObject (:id existing))
Configuration options:
- :model-type - String for RemoteSyncObject model_type (e.g. \"Card\")
- :event-prefix - Keyword prefix for events (e.g. :event/card)
- :log-name - String for log messages (e.g. \"card\")
- :hydrate-details - Function (fn [model-id]) that returns map with :name, :collection_id,
and optionally :display, :table_id, :table_name
- :archived-key - Key to check for archived status (default :archived)
- :in-sync-pred - Predicate fn (default model-in-remote-synced-collection?)"
[event-group {:keys [model-type event-prefix log-name hydrate-details archived-key in-sync-pred]
:or {archived-key :archived
in-sync-pred `model-in-remote-synced-collection?}}]
(let [parent-kw (keyword (str *ns*) (str (name event-group) "-change-event"))
create-kw (keyword (namespace event-prefix) (str (name event-prefix) "-create"))
update-kw (keyword (namespace event-prefix) (str (name event-prefix) "-update"))
delete-kw (keyword (namespace event-prefix) (str (name event-prefix) "-delete"))]
`(do
(derive ~parent-kw :metabase/event)
(derive ~create-kw ~parent-kw)
(derive ~update-kw ~parent-kw)
(derive ~delete-kw ~parent-kw)
;; Already deleted - just update status
(= "delete" (:status existing))
(t2/update! :model/RemoteSyncObject (:id existing)
{:status status
:status_changed_at (t/offset-date-time)})
(methodical/defmethod events/publish-event! ~parent-kw
[topic# event#]
(let [{:keys [~'object]} event#
in-remote-synced?# (~in-sync-pred ~'object)
existing-entry# (t2/select-one :model/RemoteSyncObject
:model_type ~model-type
:model_id (:id ~'object))
status# (if (get ~'object ~archived-key)
"delete"
(case topic#
~create-kw "create"
~update-kw "update"
~delete-kw "delete"))]
(cond
in-remote-synced?#
(do
(log/infof "Creating remote sync object entry for %s %s (status: %s)"
~log-name (:id ~'object) status#)
(create-or-update-remote-sync-object-entry!
~model-type (:id ~'object) status# ~hydrate-details))
;; Status is not "create" - update with new details
(not= "create" (:status existing))
(let [model-details (spec/hydrate-model-details model-spec model-id)
fields (spec/build-sync-object-fields model-spec model-details)]
(t2/update! :model/RemoteSyncObject (:id existing)
(merge {:status status
:status_changed_at (t/offset-date-time)}
fields))))))
(and existing-entry# (not in-remote-synced?#))
(do
(log/infof "%s %s moved out of remote-synced collection, marking as removed"
~log-name (:id ~'object))
(create-or-update-remote-sync-object-entry!
~model-type (:id ~'object) "removed" ~hydrate-details))))))))
(defn- handle-model-event-from-spec
"Generic event handler that uses a spec for all configuration.
Checks eligibility, determines status, and creates/updates the sync object."
[model-spec topic {:keys [object]}]
(let [model-type (:model-type model-spec)
model-id (:id object)
eligible? (spec/check-eligibility model-spec object)
existing-entry (t2/select-one :model/RemoteSyncObject :model_type model-type :model_id model-id)
status (spec/determine-status model-spec topic object)]
(cond
;; Model is eligible for sync
eligible?
(do
(log/infof "Creating remote sync object entry for %s %s (status: %s)"
model-type model-id status)
(create-or-update-sync-object-from-spec! model-spec model-id status))
;; Standard model change handlers
;; Model was synced but no longer eligible - mark as removed
(and existing-entry (not eligible?))
(do
(log/infof "%s %s moved out of sync scope, marking as removed" model-type model-id)
(create-or-update-sync-object-from-spec! model-spec model-id "removed")))))
(defmodel-change-handler :card
{:model-type "Card"
:event-prefix :event/card
:log-name "card"
:hydrate-details (fn [id] (t2/select-one [:model/Card :name :collection_id :display] :id id))})
(defn- register-events-for-spec!
"Registers event handlers for a single spec. Creates event hierarchy and
registers a methodical handler for the parent event."
[model-spec]
(let [event-kws (spec/event-keywords model-spec)
parent-kw (:parent event-kws)]
;; Derive event hierarchy
(derive parent-kw :metabase/event)
(doseq [[_event-type event-kw] (dissoc event-kws :parent)]
(derive event-kw parent-kw))
(defmodel-change-handler :dashboard
{:model-type "Dashboard"
:event-prefix :event/dashboard
:log-name "dashboard"
:hydrate-details (fn [id] (t2/select-one [:model/Dashboard :name :collection_id] :id id))})
;; Register the handler using methodical's runtime API
;; (defmethod requires compile-time dispatch values, so we use add-primary-method!)
;; Note: We use #'events/publish-event! (the var) because add-primary-method! expects a var/atom
;; The method signature matches defmethod: [topic event], not [next-method topic event]
(methodical/add-primary-method!
#'events/publish-event!
parent-kw
(fn [topic event]
(handle-model-event-from-spec model-spec topic event)))))
(defmodel-change-handler :document
{:model-type "Document"
:event-prefix :event/document
:log-name "document"
:hydrate-details (fn [id] (t2/select-one [:model/Document :name :collection_id] :id id))})
;;; --------------------------------- Spec-based Event Registration (Non-Collection) -----------------------------------
;; Register event handlers for models that use standard spec-based handling.
;; Collection has special handling and is registered separately below.
(defmodel-change-handler :snippet
{:model-type "NativeQuerySnippet"
:event-prefix :event/snippet
:log-name "snippet"
:hydrate-details (fn [id] (t2/select-one [:model/NativeQuerySnippet :name :id] :id id))})
(doseq [[_model-key model-spec] (dissoc spec/remote-sync-specs :model/Collection)]
(register-events-for-spec! model-spec))
;;; ----------------------------------------- Collection Event Handler -------------------------------------------------
;; Collection has special handling due to side effects (tracking published tables when
;; a collection becomes remote-synced). This handler is kept separate from the standard
;; spec-based registration.
;; Collection create/update events - derive from common parent for shared handling
(derive ::collection-change-event :metabase/event)
@@ -176,6 +178,18 @@
[id]
(t2/select-one [:model/Collection :name [:id :collection_id]] :id id))
(defn- transforms-namespace-collection?
"Check if this is a transforms-namespace collection."
[collection]
(= (keyword (:namespace collection)) :transforms))
(defn- should-sync-collection?
"Check if a collection should be synced - either remote-synced or transforms-namespace with setting enabled."
[collection]
(or (collections/remote-synced-collection? collection)
(and (settings/remote-sync-transforms)
(transforms-namespace-collection? collection))))
(defn- hydrate-table-details
"Hydrates details for a Table. For tables, table_id and table_name refer to themselves."
[id]
@@ -203,96 +217,36 @@
(methodical/defmethod events/publish-event! ::collection-change-event
[topic event]
(let [{:keys [object]} event
should-sync? (should-sync-collection? object)
is-remote-synced? (collections/remote-synced-collection? object)
existing-entry (t2/select-one :model/RemoteSyncObject :model_type "Collection" :model_id (:id object))
was-remote-synced? (and existing-entry
(not (contains? #{"removed"} (:status existing-entry))))
was-synced? (and existing-entry
(not (contains? #{"removed"} (:status existing-entry))))
status (if (:archived object)
"delete"
(case topic
:event/collection-create "create"
:event/collection-update "update"))]
(cond
;; Collection is remote-synced - create or update entry
is-remote-synced?
;; Collection should be synced (remote-synced or transforms-namespace with setting enabled)
should-sync?
(do
(log/infof "Creating remote sync object entry for collection %s (status: %s)" (:id object) status)
(create-or-update-remote-sync-object-entry! "Collection" (:id object) status hydrate-collection-details)
;; If collection just became remote-synced, track all published tables in it
(when (not was-remote-synced?)
(track-published-tables-in-collection! (:id object))))
;; (only for actual remote-synced collections, not transforms-namespace)
(when (and is-remote-synced? (not was-synced?))
(track-published-tables-in-collection! (:id object)))
;; When a transforms-namespace collection is archived, also mark child transforms for deletion
(when (and (:archived object) (transforms-namespace-collection? object))
(let [transform-ids (t2/select-pks-set :model/Transform :collection_id (:id object))]
(doseq [transform-id transform-ids]
(log/infof "Marking transform %s for deletion (parent collection archived)" transform-id)
(create-or-update-remote-sync-object-entry! "Transform" transform-id "delete"
(fn [id] (t2/select-one [:model/Transform :name [:collection_id :model_collection_id]] :id id)))))))
;; Collection was remote-synced but type changed - mark as removed
(and existing-entry (not is-remote-synced?))
;; Collection was synced but no longer should be - mark as removed
(and existing-entry (not should-sync?))
(do
(log/infof "Collection %s type changed from remote-synced, marking as removed" (:id object))
(log/infof "Collection %s no longer needs syncing, marking as removed" (:id object))
(create-or-update-remote-sync-object-entry! "Collection" (:id object) "removed" hydrate-collection-details)))))
(defmodel-change-handler :timeline
{:model-type "Timeline"
:event-prefix :event/timeline
:log-name "timeline"
:hydrate-details (fn [id] (t2/select-one [:model/Timeline :name :collection_id] :id id))})
;; Table events - only track published tables in remote-synced collections
(defn- published-table-in-remote-synced-collection?
"Check if a table is published AND in a remote-synced collection.
Tables are only considered part of remote sync when they are both published
and their collection_id points to a remote-synced collection."
[{:keys [is_published collection_id]}]
(boolean
(and is_published
(collections/remote-synced-collection? collection_id))))
(defn- model-in-published-table-in-remote-synced-collection?
"Check if a model (field, segment) belongs to a published table in a remote-synced collection.
The model must have a :table_id that points to a table with :is_published true
and :collection_id in a remote-synced collection."
[{:keys [table_id]}]
(boolean
(when table_id
(when-let [table (t2/select-one :model/Table :id table_id)]
(published-table-in-remote-synced-collection? table)))))
(defmodel-change-handler :table
{:model-type "Table"
:event-prefix :event/table
:log-name "table"
:archived-key :archived_at
:in-sync-pred published-table-in-remote-synced-collection?
:hydrate-details hydrate-table-details})
;; Segment events - track segments in published tables in remote-synced collections
(defn- hydrate-segment-details
"Hydrates details for a Segment, including parent table info."
[id]
(first (t2/query {:select [:s.name :s.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
:from [[:segment :s]]
:join [[:metabase_table :t] [:= :s.table_id :t.id]]
:where [:= :s.id id]})))
(defmodel-change-handler :segment
{:model-type "Segment"
:event-prefix :event/segment
:log-name "segment"
:in-sync-pred model-in-published-table-in-remote-synced-collection?
:hydrate-details hydrate-segment-details})
;; Field events - track fields in published tables in remote-synced collections
(defn- hydrate-field-details
"Hydrates details for a Field, including parent table info."
[id]
(first (t2/query {:select [:f.name :f.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
:from [[:metabase_field :f]]
:join [[:metabase_table :t] [:= :f.table_id :t.id]]
:where [:= :f.id id]})))
(defmodel-change-handler :field
{:model-type "Field"
:event-prefix :event/field
:log-name "field"
:in-sync-pred model-in-published-table-in-remote-synced-collection?
:hydrate-details hydrate-field-details})

View File

@@ -9,6 +9,7 @@
[metabase-enterprise.remote-sync.source :as source]
[metabase-enterprise.remote-sync.source.ingestable :as source.ingestable]
[metabase-enterprise.remote-sync.source.protocol :as source.p]
[metabase-enterprise.remote-sync.spec :as spec]
[metabase-enterprise.serialization.core :as serialization]
[metabase.analytics.core :as analytics]
[metabase.api.common :as api]
@@ -27,105 +28,17 @@
[]
(t2/select-pks-vec :model/Collection :is_remote_synced true))
(defn- model-fields-for-sync
"Returns the fields to select for a given model type when syncing."
[model-name]
(case model-name
"Card" [:id :name :collection_id :display]
"NativeQuerySnippet" [:id :name]
"Collection" [:id :name [:id :collection_id]]
"Segment" [:id :name :table_id]
[:id :name :collection_id]))
(defn- sync-objects!
"Populates the remote-sync-object table with imported entities. Deletes all existing RemoteSyncObject records and
inserts new ones for each imported entity, marking them as 'synced' with the given timestamp.
Takes a timestamp instant, a map of imported entities grouped by model name (for standard models with entity_id),
and separate path collections for Table and Field models which use name-based identity."
and separate path collections for Table and Field models which use name-based identity.
Uses the spec system to determine how to query and build sync objects for each model type."
[timestamp imported-entities-by-model table-paths field-paths]
(t2/delete! :model/RemoteSyncObject)
(let [;; Standard models use entity_id UUIDs (excluding Segment which needs special handling)
;; TODO(edpaget 2026-01-19): Actions are not dirty tracked
standard-inserts (->> (dissoc imported-entities-by-model "Segment" "Action")
(mapcat (fn [[model entity-ids]]
(when (seq entity-ids)
(let [fields (model-fields-for-sync model)
model-kw (keyword "model" model)]
(t2/select (into [model-kw] fields) :entity_id [:in entity-ids])))))
(map (fn [{:keys [id name collection_id display] :as model}]
{:model_type (clojure.core/name (t2/model model))
:model_id id
:model_name name
:model_collection_id collection_id
:model_display (some-> display clojure.core/name)
:model_table_id nil
:model_table_name nil
:status "synced"
:status_changed_at timestamp})))
;; Segments: need to join to get table info
segment-entity-ids (get imported-entities-by-model "Segment")
segment-inserts (when (seq segment-entity-ids)
(->> (t2/query {:select [:s.id :s.name :s.table_id [:t.name :table_name] [:t.collection_id :collection_id]]
:from [[:segment :s]]
:join [[:metabase_table :t] [:= :s.table_id :t.id]]
:where [:in :s.entity_id segment-entity-ids]})
(map (fn [{:keys [id name table_id table_name collection_id]}]
{:model_type "Segment"
:model_id id
:model_name name
:model_collection_id collection_id
:model_display nil
:model_table_id table_id
:model_table_name table_name
:status "synced"
:status_changed_at timestamp}))))
;; Tables: batch lookup using join - include name and collection_id
;; For tables, table_id = self id, table_name = self name
table-inserts (when (seq table-paths)
(->> (t2/query {:select [:t.id :t.name :t.collection_id]
:from [[:metabase_table :t]]
:join [[:metabase_database :db] [:= :db.id :t.db_id]]
:where (into [:or]
(for [{:keys [db_name schema table_name]} table-paths]
[:and
[:= :db.name db_name]
(if schema [:= :t.schema schema] [:is :t.schema nil])
[:= :t.name table_name]]))})
(map (fn [{:keys [id name collection_id]}]
{:model_type "Table"
:model_id id
:model_name name
:model_collection_id collection_id
:model_display nil
:model_table_id id
:model_table_name name
:status "synced"
:status_changed_at timestamp}))))
;; Fields: batch lookup using join through table - include name and collection_id from table
field-inserts (when (seq field-paths)
(->> (t2/query {:select [:f.id :f.name :f.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
:from [[:metabase_field :f]]
:join [[:metabase_table :t] [:= :t.id :f.table_id]
[:metabase_database :db] [:= :db.id :t.db_id]]
:where (into [:or]
(for [{:keys [db_name schema table_name field_name]} field-paths]
[:and
[:= :db.name db_name]
(if schema [:= :t.schema schema] [:is :t.schema nil])
[:= :t.name table_name]
[:= :f.name field_name]]))})
(map (fn [{:keys [id name table_id table_name collection_id]}]
{:model_type "Field"
:model_id id
:model_name name
:model_collection_id collection_id
:model_display nil
:model_table_id table_id
:model_table_name table_name
:status "synced"
:status_changed_at timestamp}))))
all-inserts (concat standard-inserts segment-inserts table-inserts field-inserts)]
(let [all-inserts (spec/sync-all-entities! timestamp imported-entities-by-model table-paths field-paths)]
(when (seq all-inserts)
(t2/insert! :model/RemoteSyncObject all-inserts))))
@@ -133,30 +46,28 @@
"Deletes any remote sync content that was NOT part of the import.
Takes a sequence of remote-synced collection IDs and a map of imported entities grouped by model name. For each
model type (Collection, Card, Dashboard, etc.), deletes any entities in the remote-synced collections whose
entity_id is not in the imported set."
entity-id based model, deletes entities whose entity_id is not in the imported set.
Models with :scope-key in their spec are scoped to synced collections (using :id for Collection, :collection_id
for others). Models without :scope-key (like TransformTag) are deleted globally by entity_id."
[synced-collection-ids imported-entities-by-model]
(when (seq synced-collection-ids)
(doseq [model [:model/Collection
:model/Card
:model/Dashboard
:model/NativeQuerySnippet
:model/Timeline
:model/Document]
:let [serdes-model (name model)
entity-ids (get imported-entities-by-model serdes-model [])]]
(if (= model :model/Collection)
(t2/delete! :model/Collection
:id [:in synced-collection-ids]
;; if we didn't sync any, then delete all collections in the remote sync
:entity_id (if (seq entity-ids)
[:not-in entity-ids]
:entity_id))
(t2/delete! model
:collection_id [:in synced-collection-ids]
:entity_id (if (seq entity-ids)
[:not-in entity-ids]
:entity_id))))))
(doseq [[model-key model-spec] (spec/specs-by-identity-type :entity-id)
:let [serdes-model (:model-type model-spec)
entity-ids (get imported-entities-by-model serdes-model [])
entity-id-clause (if (seq entity-ids)
[:not-in entity-ids]
:entity_id)
;; :scope-key determines collection scoping - nil means global
scope-key (get-in model-spec [:removal :scope-key])]]
(if scope-key
;; Collection-scoped: delete only within synced collections
(when (seq synced-collection-ids)
(t2/delete! model-key
scope-key [:in synced-collection-ids]
:entity_id entity-id-clause))
;; Global: delete by entity_id only
(t2/delete! model-key
:entity_id entity-id-clause))))
(defn source-error-message
"Constructs user-friendly error messages from remote sync source exceptions.
@@ -181,6 +92,9 @@
(some-> e ex-cause ex-message (str/includes? "Can't create a tenant collection without tenants enabled"))
"This repository contains tenant collections, but the tenants feature is disabled on your instance."
(str/includes? (ex-message e) "Missing commit")
"Repository cache is stale: the remote repository may have been force-pushed. Please retry the operation."
:else
(format "Failed to reload from git repository: %s" (ex-message e))))
@@ -226,7 +140,10 @@
:version (source.p/version snapshot)
:message (format "Skipping import: snapshot version %s matches last imported version" snapshot-version)}
(log/infof (:message <>)))
(let [ingestable-snapshot (->> (source.p/->ingestable snapshot {:path-filters [#"collections/.*" #"databases/.*" #"actions/.*"]})
(let [path-filters (cond-> [#"collections/.*" #"databases/.*" #"actions/.*"]
(settings/remote-sync-transforms)
(conj #"transforms/.*"))
ingestable-snapshot (->> (source.p/->ingestable snapshot {:path-filters path-filters})
(source.ingestable/wrap-progress-ingestable task-id 0.7))
load-result (serdes/with-cache
(serialization/load-metabase! ingestable-snapshot))
@@ -290,90 +207,33 @@
exceptions may be thrown during export and are caught and converted to error status maps."
[^SourceSnapshot snapshot task-id message]
(if snapshot
(let [sync-timestamp (t/instant)
collections (t2/select-fn-set :entity_id :model/Collection :is_remote_synced true :location "/")]
(if (empty? collections)
{:status :error
:message "No remote-synced collections available to sync."}
(try
(analytics/inc! :metabase-remote-sync/exports)
(serdes/with-cache
(let [models (serialization/extract {:targets (mapv #(vector "Collection" %) collections)
:no-collections false
:no-data-model true
:no-settings true
:no-transforms true
:include-field-values false
:include-database-secrets false
:continue-on-error false
:skip-archived true})]
(let [sync-timestamp (t/instant)]
(try
(analytics/inc! :metabase-remote-sync/exports)
(serdes/with-cache
(if-let [models (spec/extract-entities-for-export)]
(do
(remote-sync.task/update-progress! task-id 0.3)
(let [top-level-removed-prefixes (->> (t2/query {:select [:c.entity_id]
:from [[:collection :c]]
:join [[:remote_sync_object :rso]
[:and [:= :rso.model_type [:inline "Collection"]]
[:= :rso.status [:inline "removed"]]
[:= :rso.model_id :c.id]]]
:where [:= :location "/"]})
(map #(str "collections/" (:entity_id %))))
;; Add removed table paths - join to get db_name + schema in one query
removed-table-paths (->> (t2/query {:select [[:t.name :table_name] :t.schema [:db.name :db_name]]
:from [[:metabase_table :t]]
:join [[:remote_sync_object :rso]
[:and [:= :rso.model_type [:inline "Table"]]
[:= :rso.status [:inline "removed"]]
[:= :rso.model_id :t.id]]
[:metabase_database :db]
[:= :db.id :t.db_id]]})
(map (fn [{:keys [table_name schema db_name]}]
(str/join "/" (cond-> ["databases" db_name]
schema (conj "schemas" schema)
true (conj "tables" table_name))))))
;; Add removed/deleted segment paths - join to get all path components in one query
;; Segments use entity_id for path construction
;; Include both "removed" (moved out of remote-synced collection) and "delete" (archived)
removed-segment-paths (->> (t2/query {:select [:s.entity_id
[:t.name :table_name]
:t.schema
[:db.name :db_name]]
:from [[:segment :s]]
:join [[:remote_sync_object :rso]
[:and [:= :rso.model_type [:inline "Segment"]]
[:in :rso.status [[:inline "removed"] [:inline "delete"]]]
[:= :rso.model_id :s.id]]
[:metabase_table :t] [:= :t.id :s.table_id]
[:metabase_database :db] [:= :db.id :t.db_id]]})
(map (fn [{:keys [entity_id table_name schema db_name]}]
(str/join "/" (cond-> ["databases" db_name]
schema (conj "schemas" schema)
true (conj "tables" table_name "segments" entity_id))))))
all-delete-prefixes (concat top-level-removed-prefixes
removed-table-paths
removed-segment-paths)
(let [all-delete-prefixes (spec/build-all-removal-paths)
written-version (source/store! models all-delete-prefixes snapshot task-id message)]
(remote-sync.task/set-version! task-id written-version))
;; Delete removed Table/Field/Segment entries (they've been deleted from remote)
(t2/delete! :model/RemoteSyncObject
:model_type [:in ["Table" "Field" "Segment"]]
:status "removed")
;; Also delete archived Segment entries (status "delete")
(t2/delete! :model/RemoteSyncObject
:model_type "Segment"
:status "delete")
(t2/update! :model/RemoteSyncObject {:status "synced" :status_changed_at sync-timestamp})))
{:status :success
:version (source.p/version snapshot)}
(catch Exception e
(if (:cancelled? (ex-data e))
(log/info "Export to git repository was cancelled")
(do
(analytics/inc! :metabase-remote-sync/exports-failed)
(remote-sync.task/fail-sync-task! task-id (ex-message e))
{:status :error
:version (source.p/version snapshot)
:message (format "Failed to export to git repository: %s" (ex-message e))})))
(finally
(analytics/observe! :metabase-remote-sync/export-duration-ms (t/as (t/duration sync-timestamp (t/instant)) :millis))))))
(t2/update! :model/RemoteSyncObject {:status "synced" :status_changed_at sync-timestamp})
{:status :success
:version (source.p/version snapshot)})
{:status :error
:message "No remote-syncable content available."}))
(catch Exception e
(if (:cancelled? (ex-data e))
(log/info "Export to git repository was cancelled")
(do
(log/errorf e "Failed to export to git repository: %s" (ex-message e))
(analytics/inc! :metabase-remote-sync/exports-failed)
(remote-sync.task/fail-sync-task! task-id (ex-message e))
{:status :error
:version (source.p/version snapshot)
:message (format "Failed to export to git repository: %s" (ex-message e))})))
(finally
(analytics/observe! :metabase-remote-sync/export-duration-ms (t/as (t/duration sync-timestamp (t/instant)) :millis)))))
{:status :error
:message "Remote sync source is not enabled. Please configure MB_GIT_SOURCE_REPO_URL environment variable."}))
@@ -486,7 +346,14 @@
(u.jvm/in-virtual-thread*
(dh/with-timeout {:interrupt? true
:timeout-ms (* (settings/remote-sync-task-time-limit-ms) 10)}
(handle-task-result! (sync-fn task-id) task-id branch)))
(handle-task-result!
(try
(sync-fn task-id)
(catch Exception e
(log/error e "Remote sync task failed")
{:status :error
:message (source-error-message e)}))
task-id branch)))
task))
(defn async-import!
@@ -500,7 +367,7 @@
are unsaved changes and force? is false."
[branch force? import-args]
(let [source (source/source-from-settings branch)
has-dirty? (remote-sync.object/dirty-global?)]
has-dirty? (remote-sync.object/dirty?)]
(when (and has-dirty? (not force?))
(throw (ex-info "There are unsaved changes in the Remote Sync collection which will be overwritten by the import. Force the import to discard these changes."
{:status-code 400
@@ -542,3 +409,39 @@
(:id (async-import! (settings/remote-sync-branch) true {}))))
(u/prog1 nil
(collection/clear-remote-synced-collection!))))
(defn sync-transform-tracking!
"Called when remote-sync-transforms setting changes.
When enabled: mark all existing transforms, transform tags, and transforms-namespace collections
as 'create' for initial sync.
When disabled: remove all transform-related tracking entries."
[enabled?]
(let [timestamp (t/offset-date-time)]
(if enabled?
(do
;; Mark all transforms-namespace collections for initial sync
(doseq [coll (t2/select [:model/Collection :id :name] :namespace (name collection/transforms-ns))]
(t2/insert! :model/RemoteSyncObject
{:model_type "Collection"
:model_id (:id coll)
:model_name (:name coll)
:status "create"
:status_changed_at timestamp}))
;; Mark all existing transforms and transform tags for initial sync
(doseq [[model name-key] [[:model/Transform :name]
[:model/TransformTag :name]]]
(doseq [entity (t2/select [model :id name-key])]
(t2/insert! :model/RemoteSyncObject
{:model_type (name (last (str/split (name model) #"/")))
:model_id (:id entity)
:model_name (get entity name-key)
:status "create"
:status_changed_at timestamp}))))
;; Remove all transform-related tracking (including transforms-namespace collections)
(let [transform-coll-ids (t2/select-pks-set :model/Collection :namespace (name collection/transforms-ns))]
(t2/delete! :model/RemoteSyncObject
:model_type [:in ["Transform" "TransformTag"]])
(when (seq transform-coll-ids)
(t2/delete! :model/RemoteSyncObject
:model_type "Collection"
:model_id [:in transform-coll-ids]))))))

View File

@@ -6,6 +6,7 @@
and for Field/Segment/Table models, parent table information."
(:require
[clojure.set :as set]
[metabase-enterprise.remote-sync.spec :as spec]
[metabase.util :as u]
[methodical.core :as methodical]
[toucan2.core :as t2]))
@@ -18,27 +19,40 @@
;;; ------------------------------------------------- Public API -------------------------------------------------------
(defn dirty-global?
(defn dirty?
"Checks if any collection has changes since the last sync.
Returns true if any remote-synced object has a status other than 'synced', false otherwise."
Returns true if any remote-synced object has a status other than 'synced', false otherwise.
Excludes transform model types when transform sync is disabled."
[]
(t2/exists? :model/RemoteSyncObject :status [:not= "synced"]))
(let [excluded (spec/excluded-model-types)]
(if (empty? excluded)
(t2/exists? :model/RemoteSyncObject :status [:not= "synced"])
(t2/exists? :model/RemoteSyncObject
:status [:not= "synced"]
:model_type [:not-in excluded]))))
(defn dirty-for-global
(defn dirty-objects
"Gets all models in any collection that are dirty with their sync status.
Returns a sequence of model maps that have changed since the last remote sync,
including details about their current state and sync status."
Returns a sequence of model maps that have changed since the last remote sync,
including details about their current state and sync status.
Excludes transform model types when transform sync is disabled."
[]
(->> (t2/select :model/RemoteSyncObject :status [:not= "synced"])
(map #(-> %
(dissoc :id :status_changed_at)
(set/rename-keys {:model_id :id
:model_name :name
:model_type :model
:model_collection_id :collection_id
:model_display :display
:model_table_id :table_id
:model_table_name :table_name
:status :sync_status})
(update :model u/lower-case-en)))
(into [])))
(let [excluded (spec/excluded-model-types)
query (if (empty? excluded)
(t2/select :model/RemoteSyncObject :status [:not= "synced"])
(t2/select :model/RemoteSyncObject
:status [:not= "synced"]
:model_type [:not-in excluded]))]
(->> query
(map #(-> %
(dissoc :id :status_changed_at)
(set/rename-keys {:model_id :id
:model_name :name
:model_type :model
:model_collection_id :collection_id
:model_display :display
:model_table_id :table_id
:model_table_name :table_name
:status :sync_status})
(update :model u/lower-case-en)))
(into []))))

View File

@@ -86,6 +86,14 @@
:encryption :no
:default (* 1000 60 5))
(defsetting remote-sync-transforms
(deferred-tru "Whether to sync transforms via remote-sync. When enabled, all transforms, transform tags, and transform jobs are synced as a single unit (all-or-nothing).")
:type :boolean
:visibility :admin
:export? false
:encryption :no
:default false)
(defsetting remote-sync-check-changes-cache-ttl-seconds
(deferred-tru "Time-to-live in seconds for the remote changes check cache. Default is 60 seconds.")
:type :integer
@@ -141,7 +149,7 @@
token-to-check (if obfuscated? current-token remote-sync-token)
_ (check-git-settings! (assoc settings :remote-sync-token token-to-check))]
(t2/with-transaction [_conn]
(doseq [k [:remote-sync-url :remote-sync-token :remote-sync-type :remote-sync-branch :remote-sync-auto-import]]
(doseq [k [:remote-sync-url :remote-sync-token :remote-sync-type :remote-sync-branch :remote-sync-auto-import :remote-sync-transforms]]
(when (and (contains? settings k)
(not (and (= k :remote-sync-token) obfuscated?)))
(setting/set! k (k settings))))))))

View File

@@ -233,12 +233,15 @@
Takes a git-source map containing a :git Git instance.
Returns the default branch name as a string (without 'refs/heads/' prefix), or nil if no default branch is found."
[{:keys [^Git git]}]
(let [repo (.getRepository git)
head-ref (.findRef repo "HEAD")]
(when head-ref
(when-let [target-ref (.getTarget head-ref)]
(str/replace-first (.getName target-ref) "refs/heads/" "")))))
[{:keys [^Git git] :as git-source}]
;; Query the remote directly to get HEAD - lsRemote returns symbolic refs
(let [refs (call-remote-command (.lsRemote git) git-source)
head-ref (first (filter #(= "HEAD" (.getName ^Ref %)) refs))]
(or (when head-ref
(when (.isSymbolic ^Ref head-ref)
(when-let [target (.getTarget ^Ref head-ref)]
(str/replace-first (.getName ^Ref target) "refs/heads/" ""))))
(throw (ex-info "Failed to get a default branch for git repository." {:head-ref head-ref})))))
(defn write-files!
"Writes multiple files to the git repository and commits the changes.
@@ -250,9 +253,13 @@
- :path and :content keys for writing/updating a file
- :path and :remove? true for recursively removing all files at that path
Replaces all files in the branch organized by collection prefix - files not in the provided list but in the same
collection prefix will be deleted. Removal entries with empty paths are no-ops. Removing non-existent paths
is also a no-op (idempotent).
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).
Returns the version written. Throws ExceptionInfo if the write or push
operation fails."
@@ -264,21 +271,35 @@
(with-open [inserter (.newObjectInserter repo)]
(let [index (DirCache/newInCore)
builder (.builder index)
updated-prefixes (into #{}
;; 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
(map (fn [{:keys [path content remove?]}]
(when-not remove?
(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)))
(when (not-empty path)
(path-prefix path))))
(remove nil?))
(filter :remove?)
(map :path)
(remove str/blank?))
files)]
;; Copy existing tree entries, excluding files under updated-prefixes
;; Add new/updated files to the index
(doseq [{:keys [path content remove?]} files
:when (and (not remove?) (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
(when parent-id
(with-open [rev-walk (RevWalk. repo)
tree-walk (TreeWalk. repo)]
@@ -286,8 +307,10 @@
(.addTree tree-walk (.getTree commit))
(.setRecursive tree-walk true)
(while (.next tree-walk)
(let [path (.getPathString tree-walk)]
(when-not (matches-prefix path updated-prefixes)
(let [path (.getPathString tree-walk)
existing-prefix (path-prefix path)]
(when-not (or (contains? write-prefixes existing-prefix)
(matches-prefix path removal-prefixes))
(let [entry (doto (DirCacheEntry. path)
(.setFileMode (.getFileMode tree-walk 0))
(.setObjectId (.getObjectId tree-walk 0)))]
@@ -396,7 +419,32 @@
(version [this]
(:version this)))
(defn- snapshot
(def ^:private jgit (atom {}))
(defn- stale-cache-error?
"Returns true if the exception indicates a stale git cache (e.g., after a force-push on the remote)."
[^Exception e]
(some-> (ex-message e) (str/includes? "Missing commit")))
(defn- clear-cached-repo!
"Clears a cached git repository from memory and disk."
[^File repo-path]
(log/info "Clearing stale git cache" {:repo-path (str repo-path)})
(swap! jgit dissoc (.getPath repo-path))
(FileUtils/deleteDirectory repo-path))
(defn- get-jgit [^File path {:keys [remote-url token] :as args}]
(if-let [obj (get @jgit (.getPath path))]
obj
(get (swap! jgit assoc (.getPath path) (u/prog1 (open-jgit path {:remote-url remote-url
:token token})
(when-not (has-data? (assoc args :git <>))
(FileUtils/deleteDirectory path)
(throw (ex-info "Cannot connect to uninitialized repository" {:url remote-url})))))
(.getPath path))))
(defn- snapshot*
"Internal snapshot implementation. Returns a GitSnapshot or throws."
[source]
(fetch! source)
(let [version (commit-sha source (:branch source))]
@@ -404,6 +452,21 @@
(->GitSnapshot (:git source) (:remote-url source) (:branch source) version (:token source))
(throw (ex-info (str "Invalid branch: " (:branch source)) {})))))
(defn- snapshot
"Creates a snapshot, recovering from stale cache errors by re-cloning."
[{:keys [remote-url token] :as source}]
(try
(snapshot* source)
(catch Exception e
(if (stale-cache-error? e)
(let [path (repo-path {:remote-url remote-url :token token})]
(clear-cached-repo! path)
(let [fresh-git (get-jgit path {:remote-url remote-url :token token})
fresh-source (assoc source :git fresh-git)]
(log/info "Retrying snapshot after clearing stale cache")
(snapshot* fresh-source)))
(throw e)))))
(defrecord GitSource [git remote-url branch token]
source.p/Source
(branches [source] (branches source))
@@ -417,18 +480,6 @@
(snapshot [this]
(snapshot this)))
(def ^:private jgit (atom {}))
(defn- get-jgit [^File path {:keys [remote-url token] :as args}]
(if-let [obj (get @jgit (.getPath path))]
obj
(get (swap! jgit assoc (.getPath path) (u/prog1 (open-jgit path {:remote-url remote-url
:token token})
(when-not (has-data? (assoc args :git <>))
(FileUtils/deleteDirectory path)
(throw (ex-info "Cannot connect to uninitialized repository" {:url remote-url})))))
(.getPath path))))
(defn git-source
"Creates a new GitSource instance for a git repository.

View File

@@ -0,0 +1,809 @@
(ns metabase-enterprise.remote-sync.spec
"Centralized specifications for remote-syncable models.
Each spec defines everything needed for a model to participate in remote sync:
- Event handling (what events to listen to, how to determine status)
- Eligibility checking (when should this model be tracked)
- Dirty tracking (how to create/update RemoteSyncObject entries)
- Identity resolution (for import/export matching)
- Path construction (for serialization paths)
To add a new syncable model, add a spec entry to `remote-sync-specs` and
optionally implement custom multimethods if the default behavior doesn't fit."
(:require
[clojure.string :as str]
[clojure.walk :as walk]
[metabase-enterprise.remote-sync.settings :as rs-settings]
[metabase.collections.core :as collections]
[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)
;;; ------------------------------------------------- Spec Definitions -------------------------------------------------
(def remote-sync-specs
"Map of model keyword to its remote-sync specification.
Each spec contains:
- :model-type - String name for RemoteSyncObject model_type column
- :model-key - Toucan2 model keyword (e.g., :model/Card)
- :identity - Identity strategy: :entity-id, :path, or :hybrid
- :path-keys - For path-based: vector of path components [:database :schema :table :field]
- :events - Event configuration map:
:prefix - Event keyword prefix (e.g., :event/card)
:types - Vector of event types to handle [:create :update :delete]
- :eligibility - Eligibility configuration:
:type - :collection, :published-table, :parent-table, or :setting
:collection - For :collection type: :remote-synced, :transforms-namespace, or :any
:setting - For :setting type: setting keyword to check
- :archived-key - Key to check for archived status (nil if model has no archived concept)
- :tracking - RemoteSyncObject field configuration:
:select-fields - Fields to select for hydration
:hydrate-query - Optional custom query for joins (overrides select-fields)
:field-mappings - Map of RemoteSyncObject column -> source field or [field transform-fn]
- :removal - Removal/cleanup configuration:
:statuses - Set of statuses to check for removal (e.g., #{\"removed\" \"delete\"})
:scope-key - Optional key for scoping deletions (e.g., :collection_id, :id).
If nil, deletions are global (by entity_id only).
- :export-path - Export path construction:
:type - Path type: :collection-entity, :table-path, :field-path, :segment-path,
:transform-path, :transform-tag-path
- :side-effects - Optional side effects:
:on-first-sync - Function to call when model first becomes synced
- :enabled? - true or setting keyword (e.g., :remote-sync-transforms)"
{:model/Card
{:model-type "Card"
:model-key :model/Card
:identity :entity-id
:events {:prefix :event/card
:types [:create :update :delete]}
:eligibility {:type :collection
:collection :remote-synced}
:archived-key :archived
:tracking {:select-fields [:name :collection_id :display]
:field-mappings {:model_name :name
:model_collection_id :collection_id
:model_display [:display #(some-> % name)]}}
:removal {:statuses #{"removed"}
:scope-key :collection_id}
:export-path {:type :collection-entity}
:enabled? true}
:model/Dashboard
{:model-type "Dashboard"
:model-key :model/Dashboard
:identity :entity-id
:events {:prefix :event/dashboard
:types [:create :update :delete]}
:eligibility {:type :collection
:collection :remote-synced}
:archived-key :archived
:tracking {:select-fields [:name :collection_id]
:field-mappings {:model_name :name
:model_collection_id :collection_id}}
:removal {:statuses #{"removed"}
:scope-key :collection_id}
:export-path {:type :collection-entity}
:enabled? true}
:model/Document
{:model-type "Document"
:model-key :model/Document
:identity :entity-id
:events {:prefix :event/document
:types [:create :update :delete]}
:eligibility {:type :collection
:collection :remote-synced}
:archived-key :archived
:tracking {:select-fields [:name :collection_id]
:field-mappings {:model_name :name
:model_collection_id :collection_id}}
:removal {:statuses #{"removed"}
:scope-key :collection_id}
:export-path {:type :collection-entity}
:enabled? true}
:model/NativeQuerySnippet
{:model-type "NativeQuerySnippet"
:model-key :model/NativeQuerySnippet
:identity :entity-id
:events {:prefix :event/snippet
:types [:create :update :delete]}
:eligibility {:type :collection
:collection :remote-synced}
:archived-key :archived
:tracking {:select-fields [:name :id] ; snippets don't have collection_id directly
:field-mappings {:model_name :name}}
:removal {:statuses #{"removed"}
:scope-key :collection_id}
:export-path {:type :collection-entity}
:enabled? true}
:model/Timeline
{:model-type "Timeline"
:model-key :model/Timeline
:identity :entity-id
:events {:prefix :event/timeline
:types [:create :update :delete]}
:eligibility {:type :collection
:collection :remote-synced}
:archived-key :archived
:tracking {:select-fields [:name :collection_id]
:field-mappings {:model_name :name
:model_collection_id :collection_id}}
:removal {:statuses #{"removed"}
:scope-key :collection_id}
:export-path {:type :collection-entity}
:enabled? true}
:model/Collection
{:model-type "Collection"
:model-key :model/Collection
:identity :entity-id
:events {:prefix :event/collection
:types [:create :update]} ; no delete event for collections
:eligibility {:type :collection
:collection :any} ; remote-synced OR transforms-namespace
:archived-key :archived
:tracking {:select-fields [:name :id]
:field-mappings {:model_name :name
:model_collection_id :id}} ; collection_id is self
:removal {:statuses #{"removed" "delete"} ; delete is set when collection is archived
:scope-key :id} ; collections are scoped by their own id
:export-path {:type :collection-entity}
:export-scope :root-collections ; query for root-level remote-synced + transforms-namespace collections
:side-effects {:on-first-sync :track-published-tables}
:enabled? true}
:model/Table
{:model-type "Table"
:model-key :model/Table
:identity :path
:path-keys [:database :schema :table]
:events {:prefix :event/table
:types [:create :update :delete]}
:eligibility {:type :published-table}
:archived-key :archived_at ; tables use archived_at, not archived
:tracking {:select-fields [:name :collection_id]
:field-mappings {:model_name :name
:model_collection_id :collection_id
:model_table_id :id
:model_table_name :name}} ; self-reference
:removal {:statuses #{"removed"}}
:export-path {:type :table-path}
:enabled? true}
:model/Field
{:model-type "Field"
:model-key :model/Field
:identity :path
:path-keys [:database :schema :table :field]
:parent-model :model/Table
:events {:prefix :event/field
:types [:create :update :delete]}
:eligibility {:type :parent-table}
:archived-key nil ; fields don't have archived
:tracking {:hydrate-query {:select [:f.name :f.table_id
[:t.collection_id :collection_id]
[:t.name :table_name]]
:from [[:metabase_field :f]]
:join [[:metabase_table :t] [:= :f.table_id :t.id]]
:where [:= :f.id :?id]}
:field-mappings {:model_name :name
:model_collection_id :collection_id
:model_table_id :table_id
:model_table_name :table_name}}
:removal {:statuses #{"removed"}}
:export-path {:type :field-path}
:enabled? true}
:model/Segment
{:model-type "Segment"
:model-key :model/Segment
:identity :hybrid ; entity_id but needs table context for path
:path-keys [:database :schema :table]
:parent-model :model/Table
:events {:prefix :event/segment
:types [:create :update :delete]}
:eligibility {:type :parent-table}
:archived-key :archived
:tracking {:hydrate-query {:select [:s.name :s.table_id
[:t.collection_id :collection_id]
[:t.name :table_name]]
:from [[:segment :s]]
:join [[:metabase_table :t] [:= :s.table_id :t.id]]
:where [:= :s.id :?id]}
:field-mappings {:model_name :name
:model_collection_id :collection_id
:model_table_id :table_id
:model_table_name :table_name}}
:removal {:statuses #{"removed" "delete"}} ; Segment has both
:export-path {:type :segment-path}
:enabled? true}
:model/Measure
{:model-type "Measure"
:model-key :model/Measure
:identity :hybrid ; entity_id but needs table context for path
:path-keys [:database :schema :table]
:parent-model :model/Table
:events {:prefix :event/measure
:types [:create :update :delete]}
:eligibility {:type :parent-table}
:archived-key :archived
;; Note: hydrate-query uses :s alias because query-entities-for-sync :hybrid
;; hardcodes :s.id and :s.entity_id references
:tracking {:hydrate-query {:select [:s.name :s.table_id
[:t.collection_id :collection_id]
[:t.name :table_name]]
:from [[:measure :s]]
:join [[:metabase_table :t] [:= :s.table_id :t.id]]
:where [:= :s.id :?id]}
:field-mappings {:model_name :name
:model_collection_id :collection_id
:model_table_id :table_id
:model_table_name :table_name}}
:removal {:statuses #{"removed" "delete"}}
:export-path {:type :measure-path}
:enabled? true}
:model/Transform
{:model-type "Transform"
:model-key :model/Transform
:identity :entity-id
:events {:prefix :event/transform
:types [:create :update :delete]}
:eligibility {:type :setting
:setting :remote-sync-transforms}
:archived-key :archived
:tracking {:select-fields [:name :collection_id]
:field-mappings {:model_name :name
:model_collection_id :collection_id}}
:removal {:statuses #{"removed" "delete"}} ; no scope-key = global deletion
:export-path {:type :transform-path}
:export-scope :root-only ; query for root transforms (collection_id = nil)
:enabled? :remote-sync-transforms}
:model/TransformTag
{:model-type "TransformTag"
:model-key :model/TransformTag
:identity :entity-id
:events {:prefix :event/transform-tag
:types [:create :update :delete]}
:eligibility {:type :setting
:setting :remote-sync-transforms}
:archived-key nil ; no archived field
:tracking {:select-fields [:name]
:field-mappings {:model_name :name}}
:removal {:statuses #{"removed" "delete"}} ; no scope-key = global deletion
:export-path {:type :transform-tag-path}
:export-scope :all ; query for all instances
:enabled? :remote-sync-transforms}})
;;; ------------------------------------------------- Helper Functions -------------------------------------------------
(defn spec-enabled?
"Returns true if the spec is currently enabled based on its :enabled? value."
[{:keys [enabled?]}]
(cond
(true? enabled?) true
(keyword? enabled?) (boolean (setting/get-value-of-type :boolean enabled?))
:else false))
(defn enabled-specs
"Returns a map of model-key -> spec for all currently enabled specs."
[]
(into {}
(filter (fn [[_ spec]] (spec-enabled? spec)))
remote-sync-specs))
(defn spec-for-model-type
"Returns the spec for a given model type string (e.g., \"Card\")."
[model-type-str]
(some (fn [[_ spec]]
(when (= (:model-type spec) model-type-str)
spec))
remote-sync-specs))
(defn spec-for-model-key
"Returns the spec for a given model key (e.g., :model/Card)."
[model-key]
(get remote-sync-specs model-key))
(defn specs-by-identity-type
"Returns a map of model-key -> spec filtered by identity type."
[identity-type]
(into {}
(filter (fn [[_ spec]] (= (:identity spec) identity-type)))
(enabled-specs)))
(defn excluded-model-types
"Returns a set of model type strings that should be excluded from dirty detection
based on current settings. Models with a setting-based :enabled? that is currently
false will be excluded."
[]
(->> remote-sync-specs
(filter (fn [[_ spec]]
(and (keyword? (:enabled? spec))
(not (setting/get-value-of-type :boolean (:enabled? spec))))))
(map (fn [[_ spec]] (:model-type spec)))
set))
(defn all-model-types
"Returns a set of all model type strings."
[]
(into #{} (map :model-type (vals remote-sync-specs))))
;;; ------------------------------------------------ Eligibility Checking ----------------------------------------------
(defn- transforms-namespace-collection?
"Check if this is a transforms-namespace collection."
[object]
(= (keyword (:namespace object)) :transforms))
(defmulti check-eligibility
"Determines if a model instance should be tracked for remote sync.
Dispatches on the eligibility type defined in the spec."
{:arglists '([spec object])}
(fn [spec _object] (get-in spec [:eligibility :type])))
(defmethod check-eligibility :collection
[{:keys [eligibility]} object]
(let [collection-type (:collection eligibility)
collection-id (:collection_id object)]
(case collection-type
:remote-synced
(boolean (collections/remote-synced-collection? collection-id))
:transforms-namespace
(and (rs-settings/remote-sync-transforms)
(transforms-namespace-collection? object))
:any
(or (collections/remote-synced-collection? (or collection-id object))
(and (rs-settings/remote-sync-transforms)
(transforms-namespace-collection? object)))
false)))
(defmethod check-eligibility :published-table
[_ {:keys [is_published collection_id]}]
(boolean
(and is_published
(collections/remote-synced-collection? collection_id))))
(defmethod check-eligibility :parent-table
[{:keys [parent-model]} {:keys [table_id]}]
(boolean
(when table_id
(when-let [table (t2/select-one parent-model :id table_id)]
(check-eligibility (spec-for-model-key parent-model) table)))))
(defmethod check-eligibility :setting
[{:keys [eligibility]} _object]
(boolean (setting/get-value-of-type :boolean (:setting eligibility))))
(defmethod check-eligibility :default
[_ _]
false)
;;; ---------------------------------------------------- Hydration -----------------------------------------------------
(defn hydrate-model-details
"Hydrates model details for RemoteSyncObject based on spec.
Returns a map with the fields needed to populate the sync object."
[{:keys [model-key tracking]} model-id]
(if-let [query (:hydrate-query tracking)]
;; Use custom query for joins (Field, Segment)
(first (t2/query (update query :where
(fn [where-clause]
(walk/postwalk
#(if (= % :?id) model-id %)
where-clause)))))
;; Simple select using select-fields
(when-let [fields (:select-fields tracking)]
(t2/select-one (into [model-key] fields) :id model-id))))
(defn build-sync-object-fields
"Builds the fields map for RemoteSyncObject from hydrated model details.
Applies any transform functions defined in field-mappings."
[{:keys [tracking]} model-details]
(when model-details
(into {}
(for [[sync-field source-spec] (:field-mappings tracking)]
(let [value (cond
;; Simple keyword - just get the field
(keyword? source-spec)
(get model-details source-spec)
;; Vector with transform function [field transform-fn]
(vector? source-spec)
(let [[field transform-fn] source-spec]
(transform-fn (get model-details field)))
:else
source-spec)]
[sync-field value])))))
;;; ------------------------------------------------ Identity Resolution -----------------------------------------------
(defmulti extract-identity
"Extracts the identity for a model instance based on the spec's identity strategy.
Used for matching during import/export."
{:arglists '([spec instance])}
(fn [spec _instance] (:identity spec)))
(defmethod extract-identity :entity-id
[_ instance]
{:entity-id (:entity_id instance)})
(defmethod extract-identity :path
[{:keys [path-keys]} instance]
;; Path-based identity uses the path-keys to build a unique identifier
(select-keys instance path-keys))
(defmethod extract-identity :hybrid
[{:keys [path-keys]} instance]
;; Hybrid uses both entity_id and path context
{:entity-id (:entity_id instance)
:path (select-keys instance path-keys)})
;;; --------------------------------------------- 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"
[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)))))
(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
"Determines the sync status based on event type and archived state."
[{:keys [archived-key]} topic object]
(let [archived? (and archived-key (get object archived-key))]
(if archived?
"delete"
(let [topic-name (name topic)]
(cond
(str/ends-with? topic-name "-create") "create"
(str/ends-with? topic-name "-update") "update"
(str/ends-with? topic-name "-delete") "delete"
:else "update")))))
(defn event-keywords
"Returns a map of event keywords for a spec: {:parent :create :update :delete}."
[{:keys [events]}]
(let [{:keys [prefix types]} events
ns-str (namespace prefix)
base (name prefix)]
(merge
;; Parent keyword uses the event prefix name (e.g., :card -> card-change-event)
{:parent (keyword "metabase-enterprise.remote-sync.events"
(str base "-change-event"))}
(into {}
(for [event-type types]
[event-type (keyword ns-str (str base "-" (name event-type)))])))))
;;; ---------------------------------------------- Spec Field Accessors ------------------------------------------------
(defn select-fields-for-sync
"Returns the fields to select for a model during sync operations.
Falls back to default if not specified in spec."
[model-type-str]
(if-let [spec (spec-for-model-type model-type-str)]
(or (get-in spec [:tracking :select-fields])
[: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 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
Returns paths without file extensions - the git source adds .yaml as needed."
[]
(let [ctx (serdes/storage-base-context)]
(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))))
;;; ----------------------------------------- Sync Object Query Functions --------------------------------------------
(defmulti query-entities-for-sync
"Queries entities for sync based on identity type.
Returns a sequence of maps ready for RemoteSyncObject insertion.
Parameters:
- spec: The model spec
- data: For :entity-id/:hybrid - a collection of entity_ids
For :path - a collection of path maps {:db_name :schema :table_name [:field_name]}
- timestamp: The sync timestamp"
{:arglists '([spec data timestamp])}
(fn [spec _data _timestamp] (:identity spec)))
(defmethod query-entities-for-sync :entity-id
[{:keys [model-type model-key tracking]} entity-ids timestamp]
(when (seq entity-ids)
(let [;; Get select fields from spec, with :id always included
select-fields (into [:id] (or (:select-fields tracking) [:name :collection_id]))
entities (t2/select (into [model-key] select-fields) :entity_id [:in entity-ids])]
(map (fn [entity]
(let [;; Apply field mappings
field-mappings (:field-mappings tracking)
mapped-fields (into {}
(for [[sync-field source-spec] field-mappings]
(let [value (cond
(keyword? source-spec)
(get entity source-spec)
(vector? source-spec)
(let [[field transform-fn] source-spec]
(transform-fn (get entity field)))
:else source-spec)]
[sync-field value])))]
(merge {:model_type model-type
:model_id (:id entity)
:status "synced"
:status_changed_at timestamp}
mapped-fields)))
entities))))
(defmethod query-entities-for-sync :hybrid
;; Hybrid models like Segment need a join query for table info
[{:keys [model-type tracking]} entity-ids timestamp]
(when (seq entity-ids)
(if-let [query-template (:hydrate-query tracking)]
;; Use custom hydrate query adapted for batch lookup
;; Keep the existing from/join structure, just modify select and where
;; The query template uses alias :s for the main model (segment)
(let [base-query (-> query-template
;; Add :id to select for model_id field
(update :select (fn [cols] (vec (concat [:s.id] cols))))
;; Replace where clause to use batch entity_id lookup
(assoc :where [:in :s.entity_id entity-ids]))]
(->> (t2/query base-query)
(map (fn [entity]
(let [field-mappings (:field-mappings tracking)
mapped-fields (into {}
(for [[sync-field source-spec] field-mappings]
[sync-field (get entity source-spec)]))]
(merge {:model_type model-type
:model_id (:id entity)
:status "synced"
:status_changed_at timestamp}
mapped-fields))))))
;; Fallback - shouldn't happen for hybrid models with proper spec
nil)))
(defn- build-path-where-clause
"Builds an :or where clause for path-based lookups."
[paths has-field?]
(into [:or]
(for [path paths]
(let [{:keys [db_name schema table_name field_name]} path]
(cond-> [:and
[:= :db.name db_name]
(if schema [:= :t.schema schema] [:is :t.schema nil])
[:= :t.name table_name]]
(and has-field? field_name)
(conj [:= :f.name field_name]))))))
(defmethod query-entities-for-sync :path
[{:keys [model-type model-key]} paths timestamp]
(when (seq paths)
(case model-key
:model/Table
(->> (t2/query {:select [:t.id :t.name :t.collection_id]
:from [[:metabase_table :t]]
:join [[:metabase_database :db] [:= :db.id :t.db_id]]
:where (build-path-where-clause paths false)})
(map (fn [{:keys [id name collection_id]}]
{:model_type model-type
:model_id id
:model_name name
:model_collection_id collection_id
:model_display nil
:model_table_id id
:model_table_name name
:status "synced"
:status_changed_at timestamp})))
:model/Field
(->> (t2/query {:select [:f.id :f.name :f.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
:from [[:metabase_field :f]]
:join [[:metabase_table :t] [:= :t.id :f.table_id]
[:metabase_database :db] [:= :db.id :t.db_id]]
:where (build-path-where-clause paths true)})
(map (fn [{:keys [id name table_id table_name collection_id]}]
{:model_type model-type
:model_id id
:model_name name
:model_collection_id collection_id
:model_display nil
:model_table_id table_id
:model_table_name table_name
:status "synced"
:status_changed_at timestamp})))
;; Default for unknown path models
nil)))
(defmethod query-entities-for-sync :default
[_ _ _]
nil)
(defn sync-all-entities!
"Syncs all entities based on specs. Takes:
- timestamp: The sync timestamp
- imported-entities-by-model: Map of model-type string -> set of entity_ids
- table-paths: Collection of table path maps
- field-paths: Collection of field path maps
Returns a sequence of all sync objects to insert."
[timestamp imported-entities-by-model table-paths field-paths]
(into []
(for [[model-key spec] (enabled-specs)
:let [identity-type (:identity spec)
model-type (:model-type spec)
data (case identity-type
:entity-id (get imported-entities-by-model model-type)
:path (case model-key
:model/Table table-paths
:model/Field field-paths
nil)
:hybrid (get imported-entities-by-model model-type))]
:when (seq data)
entity (query-entities-for-sync spec data timestamp)]
entity)))
;;; -------------------------------------------- Export Extraction ------------------------------------------------
(defmulti query-export-roots
"Queries for root-level export targets based on eligibility configuration.
Returns a sequence of [model-type id] tuples for initial targets, or nil if
this model type's targets are derived from other models (e.g., via serdes/descendants)."
{:arglists '([spec])}
(fn [spec] (get-in spec [:eligibility :type])))
(defmethod query-export-roots :collection
[{:keys [export-scope]}]
(case (or export-scope :derived)
:root-collections
;; Collection model: query for root-level remote-synced + transforms-namespace collections
;; Excludes archived collections - their files are handled by the removal logic
(concat
(t2/select-fn-set (juxt (constantly "Collection") :id)
:model/Collection
{:where [:and
[:= :is_remote_synced true]
[:= :location "/"]
[:not :archived]]})
(when (rs-settings/remote-sync-transforms)
(t2/select-fn-set (juxt (constantly "Collection") :id)
:model/Collection
{:where [:and
[:= :namespace (name collections/transforms-ns)]
[:= :location "/"]
[:not :archived]]})))
:derived
;; Other collection-based models: no root query, derived from collection expansion
nil))
(defmethod query-export-roots :setting
[{:keys [export-scope model-key model-type] :as spec}]
(when (spec-enabled? spec)
(case export-scope
:root-only
;; Transform: root transforms (collection_id = nil)
(t2/select-fn-set (juxt (constantly model-type) :id) model-key :collection_id nil)
:all
;; TransformTag: all instances
(t2/select-fn-set (juxt (constantly model-type) :id) model-key)
;; Default: no root query
nil)))
(defmethod query-export-roots :published-table [_] nil) ; Derived via serdes/descendants
(defmethod query-export-roots :parent-table [_] nil) ; Derived via serdes/descendants
(defmethod query-export-roots :default [_] nil)
(defn- resolve-targets
"Expands collection IDs to include all descendant collections.
Takes a set of collection IDs and returns a set including the original IDs
plus all IDs of nested collections."
[targets opts]
(when (seq targets)
(merge-with into
(u/traverse targets #(serdes/descendants (first %) (second %) opts))
(u/traverse targets #(serdes/required (first %) (second %))))))
(defn extract-entities-for-export
"Extracts all entities for remote-sync export based on enabled specs.
Returns a lazy sequence of serialized entities ready for storage.
Iterates over enabled specs and uses `query-export-roots` to find root targets
for each model type. Models with `:export-scope :derived` or no export-scope
are expanded from other targets via serdes/descendants.
Only extracts models that:
1. Have a spec in remote-sync-specs
2. Are currently enabled (based on :enabled? field)
3. Are in one of the provided collections (or descendants)"
[]
(let [specs (enabled-specs)
;; Collect all root targets by iterating over enabled specs
root-targets (into []
(comp (map val)
(mapcat query-export-roots)
(filter identity))
specs)]
(when-let [targets (resolve-targets
root-targets
{:include-field-values false
:include-database-secrets false
:continue-on-error false
:skip-archived true})]
(eduction (map (fn [[model ids]]
(serdes/extract-all model {:where [:in :id ids]})))
cat
(u/group-by first second (keys targets))))))

View File

@@ -27,7 +27,8 @@
"Document"
"Glossary"
"NativeQuerySnippet"
"Timeline"])
"Timeline"
"Transform"])
(def exported-models
"The list of all models exported by serialization by default. Used for production code and by tests."
@@ -36,7 +37,6 @@
["FieldValues"
"Metabot"
"Setting"
"Transform"
"TransformJob"
"TransformTag"]))

View File

@@ -10,21 +10,33 @@
preferred-method (case preferred-method
"jwt" :jwt
"saml" :saml
"slack-connect" :slack-connect
(throw (ex-info "Invalid auth method"
{:preferred-method preferred-method
:available [:jwt :saml]})))
:available [:jwt :saml :slack-connect]})))
(contains? (:params req) :jwt) :jwt
:else :saml)))
(defn- sso-backend
"Function that powers the defmulti in figuring out which SSO backend to use. It might be that we need to have more
complex logic around this, but now it's just a simple priority. If SAML is configured use that otherwise JWT"
complex logic around this, but now it's just a simple priority. If multiple SSO methods are enabled, uses
preferred_method parameter if provided."
[req]
(cond
(and (sso-settings/saml-enabled) (sso-settings/jwt-enabled)) (select-sso-backend req)
(sso-settings/saml-enabled) :saml
(sso-settings/jwt-enabled) :jwt
:else nil))
(let [enabled-count (count (filter identity
[(sso-settings/saml-enabled)
(sso-settings/jwt-enabled)
(sso-settings/slack-connect-enabled)]))]
(cond
;; Multiple SSO methods enabled - use preferred_method or selection logic
(> enabled-count 1) (select-sso-backend req)
;; Single SSO method enabled
(sso-settings/saml-enabled) :saml
(sso-settings/jwt-enabled) :jwt
(sso-settings/slack-connect-enabled) :slack-connect
;; No SSO method enabled
:else nil)))
(defmulti sso-get
"Multi-method for supporting the first part of an SSO signin request. An implementation of this method will usually

View File

@@ -7,6 +7,7 @@
[metabase-enterprise.sso.api.interface :as sso.i]
[metabase-enterprise.sso.integrations.jwt :as jwt]
[metabase-enterprise.sso.integrations.saml]
[metabase-enterprise.sso.integrations.slack-connect]
[metabase-enterprise.sso.settings :as sso-settings]
[metabase.api.macros :as api.macros]
[metabase.request.core :as request]

View File

@@ -3,4 +3,5 @@
[metabase-enterprise.sso.integrations.google] ; has a `define-multi-setting` impl
[metabase-enterprise.sso.providers.jwt]
[metabase-enterprise.sso.providers.saml]
[metabase-enterprise.sso.providers.slack-connect]
[metabase-enterprise.sso.settings]))

View File

@@ -0,0 +1,111 @@
(ns metabase-enterprise.sso.integrations.slack-connect
"Implementation of the Slack Connect backend for SSO.
Slack Connect uses OIDC (OpenID Connect) for authentication, which only uses GET requests.
Both the initial authorization request and the callback are handled via GET.
Flow:
1. User accesses GET /auth/sso?preferred_method=slack-connect
2. Metabase redirects to Slack authorization endpoint
3. User authenticates with Slack
4. Slack redirects back to GET /auth/sso?code=...&state=...
5. Metabase exchanges code for tokens and creates session"
(:require
[java-time.api :as t]
[metabase-enterprise.sso.api.interface :as sso.i]
[metabase-enterprise.sso.integrations.sso-utils :as sso-utils]
[metabase-enterprise.sso.settings :as sso-settings]
[metabase.api.common :as api]
[metabase.auth-identity.core :as auth-identity]
[metabase.premium-features.core :as premium-features]
[metabase.request.core :as request]
[metabase.sso.core :as sso]
[metabase.system.core :as system]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[ring.util.response :as response]))
(set! *warn-on-reflection* true)
(defn- slack-redirect-uri
"Generate the redirect URI for Slack OIDC callback."
[]
(str (system/site-url) "/auth/sso"))
(defmethod sso.i/sso-get :slack-connect
[{{:keys [code state redirect]} :params
:as request}]
;; Check premium feature is available
(premium-features/assert-has-feature :sso-slack (tru "Slack Connect authentication"))
(when-not (sso-settings/slack-connect-enabled)
(throw (ex-info (tru "Slack Connect is not enabled")
{:status-code 400})))
(cond
;; Case 1: Initial request (no code) - start auth flow
(not code)
(let [_ (when (and (= "link-only" (sso-settings/slack-connect-authentication-mode))
(not api/*current-user-id*))
(throw (ex-info (tru "Account linking requires an authenticated session")
{:status-code 401})))
redirect-url (if redirect
(sso-utils/check-sso-redirect redirect)
"/")
auth-result (auth-identity/authenticate :provider/slack-connect
(assoc request
:authenticated-user api/*current-user*
:redirect-uri (slack-redirect-uri)
:final-redirect redirect-url))]
(cond
(= :redirect (:success? auth-result))
;; Use encrypted cookie for state storage
(sso/wrap-oidc-redirect auth-result
request
:slack-connect
redirect-url
{:browser-id (:browser-id request)})
:else
(throw (ex-info (or (:message auth-result) (tru "Failed to initiate Slack authentication"))
{:status-code 500}))))
;; Case 2: Callback with code - complete authentication
;; State validation happens inside login! via the OIDC provider
code
(let [login-result (auth-identity/login! :provider/slack-connect
(assoc request
:authenticated-user api/*current-user*
:code code
:state state
:oidc-provider :slack-connect
:redirect-uri (slack-redirect-uri)
:device-info (request/device-info request)))]
(cond
(:success? login-result)
(let [final-redirect (or (:redirect-url login-result) "/")
base-response (-> (response/redirect final-redirect)
(sso/clear-oidc-state-cookie))]
(log/infof "Slack authentication successful for user %s" (get-in login-result [:user :email]))
;; In link-only mode, there's no session to set - just return the redirect
(if-let [session (:session login-result)]
(request/set-session-cookies request
base-response
session
(t/zoned-date-time (t/zone-id "GMT")))
base-response))
;; Login failed (includes state validation failures)
:else
(let [error-msg (or (:message login-result) (tru "Slack authentication failed"))]
(log/errorf "Slack authentication failed: %s" error-msg)
(throw (ex-info error-msg {:status-code 401})))))))
;; OIDC only uses GET requests, so POST should not be used.
;; Return 405 Method Not Allowed.
(defmethod sso.i/sso-post :slack-connect
[_request]
{:status 405
:headers {"Allow" "GET"}
:body (tru "POST not supported for OIDC authentication. Use GET instead.")})

View File

@@ -37,6 +37,10 @@
[_]
(maybe-throw-user-provisioning (sso-settings/jwt-user-provisioning-enabled?)))
(defmethod check-user-provisioning :slack-connect
[_]
(maybe-throw-user-provisioning (sso-settings/slack-connect-user-provisioning-enabled)))
(defn relative-uri?
"Checks that given `uri` is not an absolute (so no scheme and no host)."
[uri]

View File

@@ -134,10 +134,14 @@
;; Authentication succeeded - check account creation policy
;; TODO(edpaget): 2025/11/11 this should return an error condition instead of throwing
:else
(do (when-not (and (:user request) (get-in request [:user :is_active]))
(sso-utils/check-user-provisioning :jwt))
;; If the user was deactivated but user provisioning is allowed reactive the user
(next-method provider (assoc-in request [:user-data :is_active] true)))))
(let [provisioning-enabled? (sso-settings/jwt-user-provisioning-enabled?)]
(when-not (and (:user request) (get-in request [:user :is_active]))
(sso-utils/check-user-provisioning :jwt))
;; If the user was deactivated but user provisioning is allowed reactive the user
;; Pass provisioning status for tenant reactivation logic
(next-method provider (-> request
(assoc-in [:user-data :is_active] true)
(assoc :user-provisioning-enabled? provisioning-enabled?))))))
(defn- group-names->ids
"Translate a user's group names to a set of MB group IDs using the configured mappings"

View File

@@ -163,10 +163,14 @@
;; Authentication succeeded - check account creation policy
;; TODO(edpaget): 2025/11/11 this should return an error condition instead of throwing
:else
(do (when-not (and (:user request) (get-in request [:user :is_active]))
(sso-utils/check-user-provisioning :saml))
;; If the user was deactivated but user provisioning is allowed reactive the user
(next-method provider (assoc-in request [:user-data :is_active] true)))))
(let [provisioning-enabled? (sso-settings/saml-user-provisioning-enabled?)]
(when-not (and (:user request) (get-in request [:user :is_active]))
(sso-utils/check-user-provisioning :saml))
;; If the user was deactivated but user provisioning is allowed reactive the user
;; Pass provisioning status for tenant reactivation logic
(next-method provider (-> request
(assoc-in [:user-data :is_active] true)
(assoc :user-provisioning-enabled? provisioning-enabled?))))))
(defn- group-names->ids
"Translate a user's group names to a set of MB group IDs using the configured mappings"

View File

@@ -0,0 +1,155 @@
(ns metabase-enterprise.sso.providers.slack-connect
"Slack Connect OIDC authentication provider. Derives from the base OIDC provider
and adds Slack-specific configuration and claim extraction."
(:require
[metabase-enterprise.sso.integrations.sso-utils :as sso-utils]
[metabase-enterprise.sso.settings :as sso-settings]
[metabase.auth-identity.core :as auth-identity]
[metabase.premium-features.core :as premium-features]
[metabase.util.i18n :refer [tru]]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
;;; -------------------------------------------------- Provider Registration --------------------------------------------------
(derive :provider/slack-connect :provider/oidc)
;;; -------------------------------------------------- Constants --------------------------------------------------
(def provider-name
"Provider name for Slack Connect authentication."
"slack-connect")
(def slack-issuer-uri
"Slack's OIDC issuer URI."
"https://slack.com")
(def slack-team-image-claim
"Claim key for Slack team image in ID token."
"https://slack.com/team_image_230")
;;; -------------------------------------------------- Slack-Specific Utilities --------------------------------------------------
(defn- extract-slack-claims
"Extract Slack-specific claims from ID token.
Returns map with Slack team and user information for storage in login_attributes.
Keys are strings to match the login_attributes schema [:map-of :string :string].
NOTE: team_name is NOT included in Slack ID tokens per research.
Use team_id only or make additional API call if team name is required."
[id-token-claims]
(let [team-id-claim (sso-settings/slack-connect-attribute-team-id)]
(cond-> {}
(get id-token-claims team-id-claim)
(assoc "slack-team-id" (get id-token-claims team-id-claim))
(:sub id-token-claims)
(assoc "slack-user-id" (:sub id-token-claims))
(get id-token-claims slack-team-image-claim)
(assoc "slack-team-image" (get id-token-claims slack-team-image-claim))
(:email_verified id-token-claims)
(assoc "slack-email-verified" (str (:email_verified id-token-claims)))
(:locale id-token-claims)
(assoc "slack-locale" (:locale id-token-claims)))))
(defn- build-slack-oidc-config
"Build OIDC configuration map for parent provider.
Uses Slack settings and hardcoded Slack-specific values."
[request]
(when (and (sso-settings/slack-connect-client-id)
(sso-settings/slack-connect-client-secret))
{:client-id (sso-settings/slack-connect-client-id)
:client-secret (sso-settings/slack-connect-client-secret)
:issuer-uri slack-issuer-uri
:scopes ["openid" "profile" "email"]
:redirect-uri (get request :redirect-uri)}))
;;; -------------------------------------------------- Authentication Implementation --------------------------------------------------
(methodical/defmethod auth-identity/authenticate :provider/slack-connect
[_provider request]
(cond
(not (premium-features/enable-sso-slack?))
{:success? false
:error :feature-not-available
:message (tru "Slack Connect authentication is not available on your plan")}
(not (sso-settings/slack-connect-enabled))
{:success? false
:error :slack-connect-not-enabled
:message (tru "Slack Connect authentication is not enabled")}
(not (sso-settings/slack-connect-configured))
{:success? false
:error :slack-connect-not-configured
:message (tru "Slack Connect is not configured")}
(and (= sso-settings/slack-connect-auth-mode-link-only (sso-settings/slack-connect-authentication-mode))
(not (:code request))
(not (some-> (:authenticated-user request) deref)))
{:success? false
:error :authentication-required
:message (tru "Account linking requires an authenticated session")}
:else
(let [oidc-config (build-slack-oidc-config request)]
(if-not oidc-config
{:success? false
:error :configuration-error
:message (tru "Failed to build Slack OIDC configuration")}
(let [auth-result (next-method _provider (assoc request :oidc-config oidc-config))]
(if (and (:success? auth-result)
(:user-data auth-result))
(let [id-token-claims (or (get-in request [:id-token :claims])
(get auth-result :claims))
slack-attrs (when id-token-claims
(extract-slack-claims id-token-claims))]
(cond-> (assoc-in auth-result [:user-data :sso_source] :slack)
slack-attrs
(assoc-in [:user-data :login_attributes] slack-attrs)
slack-attrs
(assoc :slack-data slack-attrs)))
auth-result))))))
;;; -------------------------------------------------- Login Implementation --------------------------------------------------
(defn- create-auth-identity-for-link!
"Create an AuthIdentity record linking an authenticated user to their Slack identity.
Used in link-only mode where we don't create users or sessions."
[user-id provider-id]
(when (and user-id provider-id)
(when-not (t2/exists? :model/AuthIdentity
:user_id user-id
:provider provider-name)
(t2/insert! :model/AuthIdentity
{:user_id user-id
:provider provider-name
:provider_id provider-id}))))
(methodical/defmethod auth-identity/login! :provider/slack-connect
[provider {:keys [user authenticated-user user-data] :as request}]
(condp = (sso-settings/slack-connect-authentication-mode)
sso-settings/slack-connect-auth-mode-sso
(do (when-not user
(sso-utils/check-user-provisioning (keyword provider-name)))
(next-method provider request))
sso-settings/slack-connect-auth-mode-link-only
;; In link-only mode, create AuthIdentity for the authenticated user
;; but don't create a session or new user
(do
(create-auth-identity-for-link! (:id @authenticated-user) (:provider-id user-data))
(assoc request :success? true))))
(methodical/defmethod auth-identity/login! :after :provider/slack-connect
[_provider result]
(if (= sso-settings/slack-connect-auth-mode-link-only (sso-settings/slack-connect-authentication-mode))
(dissoc result :user)
result))

View File

@@ -318,13 +318,6 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e
:getter (fn [] (setting/get-value-of-type :boolean :send-new-sso-user-admin-email?))
:setter (fn [send-emails] (setting/set-value-of-type! :boolean :send-new-sso-user-admin-email? send-emails)))
(defsetting other-sso-enabled?
"Are we using an SSO integration other than LDAP or Google Auth? These integrations use the `/auth/sso` endpoint for
authorization rather than the normal login form or Google Auth button."
:visibility :public
:setter :none
:getter (fn [] (or (saml-enabled) (jwt-enabled))))
(defsetting ldap-sync-user-attributes
(deferred-tru "Should we sync user attributes when someone logs in via LDAP?")
:type :boolean
@@ -344,3 +337,93 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e
:encryption :no
:default "(member={dn})"
:audit :getter)
;;; ------------------------------------------------ Slack Connect ------------------------------------------------
(defsetting slack-connect-client-id
(deferred-tru "Client ID for your Slack app. Get this from https://api.slack.com/apps")
:encryption :when-encryption-key-set
:export? false
:feature :sso-slack
:audit :getter)
(defsetting slack-connect-client-secret
(deferred-tru "Client Secret for your Slack app")
:encryption :when-encryption-key-set
:export? false
:sensitive? true
:feature :sso-slack
:audit :no-value)
(def slack-connect-auth-mode-sso
"Authentication mode for full SSO login."
"sso")
(def slack-connect-auth-mode-link-only
"Authentication mode for account linking only (no session creation)."
"link-only")
(defsetting slack-connect-authentication-mode
(deferred-tru "Controls whether Slack can be used for SSO login or just account linking. Valid values: \"sso\" (default) or \"link-only\"")
:type :string
:export? false
:default slack-connect-auth-mode-sso
:feature :sso-slack
:audit :getter
:encryption :no
:setter (fn [new-value]
(when (and new-value
(not (contains? #{slack-connect-auth-mode-sso slack-connect-auth-mode-link-only} new-value)))
(throw (ex-info (tru "Invalid authentication mode. Must be \"sso\" or \"link-only\"")
{:status-code 400})))
(setting/set-value-of-type! :string :slack-connect-authentication-mode new-value)))
(defsetting slack-connect-user-provisioning-enabled
(deferred-tru "When a user logs in via Slack Connect, create a Metabase account for them automatically if they don''t have one.")
:type :boolean
:export? false
:default true
:feature :sso-slack
:getter (fn []
(if (= (slack-connect-authentication-mode) slack-connect-auth-mode-link-only)
false
(setting/get-value-of-type :boolean :slack-connect-user-provisioning-enabled)))
:audit :getter)
(defsetting slack-connect-attribute-team-id
(deferred-tru "Slack OIDC claim for the team/workspace ID")
:default "https://slack.com/team_id"
:export? false
:feature :sso-slack
:encryption :when-encryption-key-set
:audit :getter)
(defsetting slack-connect-configured
(deferred-tru "Are the mandatory Slack Connect settings configured?")
:type :boolean
:export? false
:default false
:feature :sso-slack
:setter :none
:getter (fn [] (boolean
(and (slack-connect-client-id)
(slack-connect-client-secret)))))
(defsetting slack-connect-enabled
(deferred-tru "Is Slack Connect authentication configured and enabled?")
:type :boolean
:export? false
:default false
:feature :sso-slack
:audit :getter
:getter (fn []
(if (slack-connect-configured)
(setting/get-value-of-type :boolean :slack-connect-enabled)
false)))
(defsetting other-sso-enabled?
"Are we using an SSO integration other than LDAP or Google Auth? These integrations use the `/auth/sso` endpoint for
authorization rather than the normal login form or Google Auth button."
:visibility :public
:setter :none
:getter (fn [] (or (saml-enabled) (jwt-enabled) (slack-connect-enabled))))

View File

@@ -81,7 +81,8 @@
[:attributes {:optional true} [:maybe tenant/Attributes]]
[:is_active {:optional true} [:maybe ms/BooleanValue]]])
(mu/defn- update-tenant!
(mu/defn update-tenant!
"Updates a tenant, publishing any necessary events after doing so"
[tenant-id :- ms/PositiveInt
{:keys [is_active] :as tenant} :- UpdateTenantArguments]
(when (false? is_active)

View File

@@ -37,26 +37,37 @@
:user/tenant-slug (t2/select-one-fn :slug :model/Tenant :id tenant_id)
:tenant-slug/tenant-id (:id existing-tenant)
:tenant-slug/slug (:slug existing-tenant)
:status-code 403}))
(and existing-tenant
(not (:is_active existing-tenant)))
(throw (ex-info "Tenant is not active"
{:tenant-slug/tenant-id (:id existing-tenant)
:tenant-slug/slug (:slug existing-tenant)
:tenant-slug/is-active (:is_active existing-tenant)
:status-code 403})))))
(defn- validate-with-tenants-disabled! [{:keys [user tenant-slug]}]
(when (or (:tenant_id user) (not-empty tenant-slug))
(throw (ex-info "Tenants and tenant users are disabled." {:status-code 403}))))
(defn- reactivate-tenant!
"Reactivate a disabled tenant and any users that were deactivated along with it."
[{tenant-id :id}]
(api.tenants/update-tenant! tenant-id {:is_active true}))
(defn- maybe-reactivate-tenant!
"If the tenant is disabled and user provisioning is enabled,
reactivate the tenant. Otherwise throw an error if the tenant is disabled."
[existing-tenant user-provisioning-enabled?]
(when (and existing-tenant (not (:is_active existing-tenant)))
(if user-provisioning-enabled?
(reactivate-tenant! existing-tenant)
(throw (ex-info "Tenant is not active"
{:tenant-slug/tenant-id (:id existing-tenant)
:tenant-slug/slug (:slug existing-tenant)
:tenant-slug/is-active (:is_active existing-tenant)
:status-code 403})))))
(defn- create-tenant-if-not-exists!
[{:as request :keys [user tenant-slug]} existing-tenant]
[{:as request :keys [user tenant-slug user-provisioning-enabled?]} existing-tenant]
(if-not (setting/get :use-tenants)
(do (validate-with-tenants-disabled! request)
request)
(do (validate-user-and-tenant-slug! user existing-tenant (boolean tenant-slug))
(maybe-reactivate-tenant! existing-tenant user-provisioning-enabled?)
(cond-> request
(boolean tenant-slug)
(assoc-in [:user-data :tenant_id]

View File

@@ -1,6 +1,5 @@
(ns metabase-enterprise.transforms.api
(:require
[macaw.core :as macaw]
[metabase-enterprise.transforms.api.transform-job]
[metabase-enterprise.transforms.api.transform-tag]
[metabase-enterprise.transforms.canceling :as transforms.canceling]
@@ -20,6 +19,7 @@
[metabase.driver.sql-jdbc.execute :as sql-jdbc.execute]
[metabase.driver.util :as driver.u]
[metabase.events.core :as events]
[metabase.lib.core :as lib]
[metabase.query-processor.compile :as qp.compile]
[metabase.query-processor.schema :as qp.schema]
[metabase.request.core :as request]
@@ -31,8 +31,10 @@
[metabase.util.malli.schema :as ms]
[ring.util.response :as response]
[toucan2.core :as t2])
;; TODO (Chris 2026-01-22) -- Remove jsqlparser imports/typehints to be SQL parser-agnostic
(:import
(java.sql PreparedStatement)
^{:clj-kondo/ignore [:metabase/no-jsqlparser-imports]}
(net.sf.jsqlparser.statement.select PlainSelect)))
(comment metabase-enterprise.transforms.api.transform-job/keep-me
@@ -122,6 +124,83 @@
[:tag_ids {:optional true} [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]]
(get-transforms query-params))
(defn- extract-all-columns-from-query
"Extracts column metadata (name and type) from a query.
Returns a sequence of maps with `:name` and `:base_type` keys, or nil if extraction fails.
The query is first compiled to native SQL, then uses PreparedStatement.getMetaData()
to inspect the query structure. This works for most modern JDBC drivers but may not
be supported by all drivers or for all query types."
[driver database-id query]
(try
(let [{:keys [query]} (qp.compile/compile query)]
(sql-jdbc.execute/do-with-connection-with-options
driver
database-id
{}
(fn [conn]
(with-open [^PreparedStatement stmt (sql-jdbc.execute/prepared-statement driver conn query [])]
(when-let [rsmeta (.getMetaData stmt)]
(seq (sql-jdbc.execute/column-metadata driver rsmeta)))))))
(catch Exception e
(log/debugf e "Failed to extract columns from query: %s" (ex-message e))
nil)))
(defn- extract-incremental-filter-columns-from-query
"Extracts column names suitable for incremental transform checkpoint filtering.
This function is specifically for incremental transform checkpoint column selection.
It only returns columns with types supported for checkpoint filtering:
- Temporal types (timestamp, timestamp with timezone)
- Numeric types (integer, float, decimal)
Text, boolean, and other types are filtered out as they are not supported for
incremental checkpointing.
Returns a vector of column names (as strings), or nil if extraction fails.
The query is first compiled to native SQL, then uses PreparedStatement.getMetaData()
to inspect the query structure. This works for most modern JDBC drivers but may not
be supported by all drivers or for all query types."
[driver database-id query]
(some->> (extract-all-columns-from-query driver database-id query)
(filter (comp transforms.util/supported-incremental-filter-type? :base_type))
(mapv :name)))
(defn- validate-incremental-column-type!
"Validates that the checkpoint column for an incremental transform has a supported type.
For MBQL/Python transforms, resolves the column from the query using the unique key.
For native queries, extracts columns from the query and checks the checkpoint-filter column.
Throws a 400 error if the column type is not supported or cannot be resolved."
[{:keys [source]}]
(when-let [{:keys [checkpoint-filter checkpoint-filter-unique-key] strategy-type :type}
(:source-incremental-strategy source)]
(when (and (= :query (:type source)) (= "checkpoint" strategy-type))
(let [{:keys [query]} source
database-id (:database query)
database (api/check-404 (t2/select-one :model/Database :id database-id))
driver-name (driver/the-initialized-driver (:engine database))]
(cond
;; For MBQL, resolve column from query metadata
checkpoint-filter-unique-key
(let [column (lib/column-with-unique-key query checkpoint-filter-unique-key)]
(api/check-400 column (deferred-tru "Checkpoint column not found in query."))
(api/check-400 (transforms.util/supported-incremental-filter-type? (:base-type column))
(deferred-tru "Checkpoint column type {0} is not supported. Only numeric and temporal types are supported for incremental filtering."
(pr-str (:base-type column)))))
;; For native query with checkpoint-filter, validate type if we can extract the column metadata
checkpoint-filter
(when-some [column-metadata (seq (extract-all-columns-from-query driver-name database-id query))]
(when-some [column (first (filter #(= checkpoint-filter (:name %)) column-metadata))]
(api/check-400 (transforms.util/supported-incremental-filter-type? (:base_type column))
(deferred-tru "Checkpoint column ''{0}'' has unsupported type {1}. Only numeric and temporal columns are supported for incremental filtering."
checkpoint-filter
(pr-str (:base_type column)))))))))))
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
;;
@@ -140,9 +219,10 @@
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
[:owner_user_id {:optional true} [:maybe ms/PositiveInt]]
[:owner_email {:optional true} [:maybe :string]]]]
(api/check-superuser)
(api/create-check :model/Transform body)
(check-database-feature body)
(check-feature-enabled! body)
(validate-incremental-column-type! body)
(api/check (not (transforms.util/target-table-exists? body))
403
@@ -168,8 +248,7 @@
(defn get-transform
"Get a specific transform."
[id]
(api/check-superuser)
(let [{:keys [target] :as transform} (api/check-404 (t2/select-one :model/Transform id))
(let [{:keys [target] :as transform} (api/read-check :model/Transform id)
target-table (transforms.util/target-table (transforms.i/target-db-id transform) target :active true)]
(-> transform
(t2/hydrate :last_run :transform_tag_ids :creator :owner)
@@ -195,9 +274,8 @@
"Get the dependencies of a specific transform."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(api/read-check :model/Transform id)
(let [id->transform (t2/select-pk->fn identity :model/Transform)
_ (api/check-404 (get id->transform id))
global-ordering (transforms.ordering/transform-ordering (vals id->transform))
dep-ids (get global-ordering id)
dependencies (map id->transform dep-ids)]
@@ -249,7 +327,7 @@
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
[:owner_user_id {:optional true} [:maybe ms/PositiveInt]]
[:owner_email {:optional true} [:maybe :string]]]]
(api/check-superuser)
(api/write-check :model/Transform id)
(let [transform (t2/with-transaction [_]
;; Cycle detection should occur within the transaction to avoid race
(let [old (t2/select-one :model/Transform id)
@@ -258,6 +336,7 @@
;; we must validate on a full transform object
(check-feature-enabled! new)
(check-database-feature new)
(validate-incremental-column-type! new)
(when (transforms.util/query-transform? old)
(when-let [{:keys [cycle-str]} (transforms.ordering/get-transform-cycle new)]
(throw (ex-info (str "Cyclic transform definitions detected: " cycle-str)
@@ -282,8 +361,7 @@
"Delete a transform."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(let [transform (api/check-404 (t2/select-one :model/Transform id))]
(let [transform (api/write-check :model/Transform id)]
(t2/delete! :model/Transform id)
(events/publish-event! :event/transform-delete
{:object transform
@@ -298,7 +376,7 @@
"Delete a transform's output table."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(api/write-check :model/Transform id)
(transforms.util/delete-target-table-by-id! id)
nil)
@@ -310,8 +388,7 @@
"Cancel the current run for a given transform."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(let [transform (api/check-404 (t2/select-one :model/Transform id))
(let [transform (api/write-check :model/Transform id)
run (api/check-404 (transform-run/running-run-for-transform-id id))]
(transform-run-cancelation/mark-cancel-started-run! (:id run))
(when (transforms.util/python-transform? transform)
@@ -326,8 +403,7 @@
"Run a transform."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(let [transform (api/check-404 (t2/select-one :model/Transform id))
(let [transform (api/write-check :model/Transform id)
_ (check-feature-enabled! transform)
start-promise (promise)]
(u.jvm/in-virtual-thread*
@@ -343,35 +419,15 @@
:run_id run-id})
(assoc :status 202)))))
(defn- extract-columns-from-query
"Attempts to extract column names from an MBQL query without executing it.
Returns a vector of column names (as strings), or nil if extraction fails.
The query is first compiled to native SQL, hen uses PreparedStatement.getMetaData()
to inspect the query structure. This works for most modern JDBC drivers but may not
be supported by all drivers or for all query types."
[driver database-id query]
(try
(let [{:keys [query]} (qp.compile/compile query)]
(sql-jdbc.execute/do-with-connection-with-options
driver
database-id
{}
(fn [conn]
(with-open [^PreparedStatement stmt (sql-jdbc.execute/prepared-statement driver conn query [])]
(when-let [rsmeta (.getMetaData stmt)]
(let [columns (sql-jdbc.execute/column-metadata driver rsmeta)]
(seq (mapv :name columns))))))))
(catch Exception e
(log/debugf e "Failed to extract columns from query: %s" (ex-message e))
nil)))
;; TODO (Chris 2026-01-22) -- Remove jsqlparser typehints to be SQL parser-agnostic
(defn- simple-native-query?
"Checks if a native SQL query string is simple enough for automatic checkpoint insertion."
[sql-string]
(try
(let [^PlainSelect parsed (macaw/parsed-query sql-string)]
;; BEWARE: The API endpoint (caller) does not have info on database engine this query should run on. Hence
;; there's no way of providing appropriate [[metabase.driver.util/macaw-options]]. `nil` is best-effort
;; adding at least default :non-resserved-words.
(let [^PlainSelect parsed (driver.u/parsed-query sql-string nil)]
(cond
(not (instance? PlainSelect parsed))
{:is_simple false
@@ -407,16 +463,19 @@
(api/check-superuser)
(simple-native-query? query))
;; TODO (Cam 2025-12-04) please add a response schema to this API endpoint, it makes it easier for our customers to
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
;;
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :post "/extract-columns"
"Extract column names from an MBQL query without executing it.
:- [:map [:columns [:maybe [:sequential :string]]]]
"Extract column names suitable for incremental transform checkpoint filtering.
Compiles the query to native SQL using [[qp.compile/compile-with-inline-parameters]],
This endpoint is specifically for populating the checkpoint column dropdown in
incremental transforms. It only returns columns with types supported for checkpoint
filtering: temporal (timestamp/tz) and numeric (int/float) types.
Text, boolean, and other unsupported column types are filtered out.
The query is compiled to native SQL using [[qp.compile/compile-with-inline-parameters]],
which handles parameterized queries with template tags. Then extracts column names
using PreparedStatement metadata.
and types using PreparedStatement metadata.
Returns a map with a :columns key containing a vector of column names (strings).
If extraction fails, returns nil for :columns."
@@ -426,9 +485,9 @@
[:query ::qp.schema/any-query]]]
(api/check-superuser)
(let [database-id (:database query)
database (api/check-404 (t2/select-one :model/Database :id database-id))
database (api/check-404 (t2/select-one :model/Database :id database-id))
driver-name (driver/the-initialized-driver (:engine database))
columns (extract-columns-from-query driver-name database-id query)]
columns (extract-incremental-filter-columns-from-query driver-name database-id query)]
{:columns columns}))
(def ^{:arglists '([request respond raise])} routes

View File

@@ -15,7 +15,9 @@
[metabase-enterprise.transforms.util
native-query-transform?
python-transform?
query-transform?]
query-transform?
transform-source-database
transform-type]
[metabase-enterprise.transforms.models.transform-run
timeout-run!])

View File

@@ -11,6 +11,7 @@
[metabase.lib.core :as lib]
[metabase.models.interface :as mi]
[metabase.models.serialization :as serdes]
[metabase.remote-sync.core :as remote-sync]
[metabase.search.core :as search.core]
[metabase.search.ingestion :as search]
[metabase.search.spec :as search.spec]
@@ -25,10 +26,24 @@
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped?]]
(derive :model/Transform trait))
;; Only superusers can access transforms
(doto :model/Transform
(derive ::mi/read-policy.superuser)
(derive ::mi/write-policy.superuser))
;; Only superusers can access transforms, and writes/creates are blocked globally in remote-sync read-only mode
(defmethod mi/can-read? :model/Transform
([_instance]
(mi/superuser?))
([model pk]
(mi/can-read? (t2/select-one model pk))))
(defmethod mi/can-write? :model/Transform
([_instance]
(and (mi/superuser?)
(remote-sync/transforms-editable?)))
([model pk]
(mi/can-write? (t2/select-one model pk))))
(defmethod mi/can-create? :model/Transform
[_model _instance]
(and (mi/superuser?)
(remote-sync/transforms-editable?)))
(defn- keywordize-source-table-refs
"Keywordize keys in source-tables map values (refs are maps, ints pass through)."
@@ -270,16 +285,27 @@
:tags (serdes/nested :model/TransformTransformTag :transform_id opts)}})
(defmethod serdes/dependencies "Transform"
[{:keys [source tags]}]
[{:keys [collection_id source tags]}]
(set
(concat
(when collection_id
[[{:model "Collection" :id collection_id}]])
(for [{tag-id :tag_id} tags]
[{:model "TransformTag" :id tag-id}])
(serdes/mbql-deps source))))
(defmethod serdes/storage-path "Transform" [transform _ctx]
(let [{:keys [id label]} (-> transform serdes/path last)]
["transforms" (serdes/storage-leaf-file-name id label)]))
(defmethod serdes/storage-path "Transform" [transform ctx]
;; Path: ["collections" "<nested ... collections>" "transforms" "<entity_id_name>"]
;; Use default collection path, then restructure similar to NativeQuerySnippet
(let [basis (serdes/storage-default-collection-path transform ctx)
file (last basis)
colls (->> basis rest (drop-last 2))] ; Drop "collections" at start, and last two elements
(concat ["collections"] colls ["transforms" file])))
(defmethod serdes/required "Transform"
[_model id]
(when-let [collection-id (t2/select-one-fn :collection_id :model/Transform :id id)]
{["Collection" collection-id] {"Transform" id}}))
(defn- maybe-extract-transform-query-text
"Return the query text (truncated to `max-searchable-value-length`) from transform source; else nil.

View File

@@ -1,5 +1,6 @@
(ns metabase-enterprise.transforms.models.transform-tag
(:require
[metabase.events.core :as events]
[metabase.models.interface :as mi]
[metabase.models.serialization :as serdes]
[metabase.util.i18n :as i18n]
@@ -58,3 +59,16 @@
(defmethod serdes/storage-path "TransformTag" [tt _ctx]
(let [{:keys [id label]} (-> tt serdes/path last)]
["transforms" "transform_tags" (serdes/storage-leaf-file-name id label)]))
;; Event hooks for remote-sync tracking
(t2/define-after-insert :model/TransformTag [tag]
(events/publish-event! :event/transform-tag-create {:object tag})
tag)
(t2/define-after-update :model/TransformTag [tag]
(events/publish-event! :event/transform-tag-update {:object tag})
tag)
(t2/define-before-delete :model/TransformTag [tag]
(events/publish-event! :event/transform-tag-delete {:object tag})
tag)

View File

@@ -5,7 +5,6 @@
[metabase-enterprise.transforms.util :as transforms.util]
[metabase.driver :as driver]
[metabase.driver.util :as driver.u]
[metabase.events.core :as events]
[metabase.lib.schema.common :as schema.common]
[metabase.query-processor.compile :as qp.compile]
[metabase.util.log :as log]
@@ -76,13 +75,10 @@
(transforms.util/run-cancelable-transform!
run-id driver transform-details
(fn [_cancel-chan] (driver/run-transform! driver transform-details opts))))
(transforms.instrumentation/with-stage-timing [run-id [:import :table-sync]]
(transforms.util/sync-target! target database)
;; This event must be published only after the sync is complete - the new table needs to be in AppDB.
(events/publish-event! :event/transform-run-complete {:object transform-details}))
;; Creating an index after sync means the filter column is known in the appdb.
;; The index would be synced the next time sync runs, but at time of writing, index sync is disabled.
(transforms.util/execute-secondary-index-ddl-if-required! transform run-id database target)))
(transforms.util/handle-transform-complete!
:run-id run-id
:transform transform
:db database)))
(catch Throwable t
(log/error t "Error executing transform")
(when start-promise

View File

@@ -75,8 +75,13 @@
["table" ::table-target]
["table-incremental" ::table-incremental-target]])
(mr/def ::id pos-int?)
(mr/def ::run-id pos-int?)
(mr/def ::transform
[:map
[:id ::id]
[:description {:optional true} [:maybe :string]]
[:name :string]
[:source [:ref ::transform-source]]

View File

@@ -9,10 +9,12 @@
[metabase-enterprise.transforms.instrumentation :as transforms.instrumentation]
[metabase-enterprise.transforms.interface :as transforms.i]
[metabase-enterprise.transforms.models.transform-run :as transform-run]
[metabase-enterprise.transforms.schema :as transforms.schema]
[metabase-enterprise.transforms.settings :as transforms.settings]
[metabase.driver :as driver]
[metabase.driver.sql.query-processor :as sql.qp]
[metabase.driver.util :as driver.u]
[metabase.events.core :as events]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
@@ -48,10 +50,15 @@
(keyword schema name)
(keyword name)))
(defn transform-type
"Get the type of a transform"
[transform]
(-> transform :source :type keyword))
(defn query-transform?
"Check if this is a query transform: native query / mbql query."
[transform]
(= :query (-> transform :source :type keyword)))
(= :query (transform-type transform)))
(defn native-query-transform?
"Check if this is a native query transform.
@@ -64,7 +71,14 @@
(defn python-transform?
"Check if this is a Python transform."
[transform]
(= :python (-> transform :source :type keyword)))
(= :python (transform-type transform)))
(defn transform-source-database
"Get the source database from a transform"
[transform]
(case (transform-type transform)
:query (-> transform :source :query :database)
:python (-> transform :source :source-database)))
(defn normalize-transform
"Normalize a transform's source query, similar to how transforms are normalized when read from the database.
@@ -214,6 +228,14 @@
[source]
(= :checkpoint (some-> source :source-incremental-strategy :type keyword)))
(defn supported-incremental-filter-type?
"Returns true if the given base-type is supported for incremental filtering.
We only support temporal (timestamp/tz) and numeric (int/float) types."
[base-type]
(or (isa? base-type :type/Temporal)
(isa? base-type :type/Number)))
(defn- source->checkpoint-filter-unique-key
"Extract the checkpoint filter column from `query` using the unique key specified in `source-incremental-strategy`."
[query source-incremental-strategy]
@@ -223,15 +245,29 @@
"Resolve the checkpoint filter column for an incremental transform.
Tries to resolve the column using the unique key first.
Falls back to looking up the column by name from the target table if a `:checkpoint-filter` is specified."
Falls back to looking up the column by name from the target table if a `:checkpoint-filter` is specified.
Validates that the resolved column has a supported type for checkpoint filtering (numeric or temporal).
Throws an exception if the column type is not supported."
[query source-incremental-strategy table metadata-provider]
(or
(source->checkpoint-filter-unique-key query source-incremental-strategy)
(when-let [field-name (-> source-incremental-strategy :checkpoint-filter)]
(when-let [field-id (t2/select-one-pk :model/Field
:table_id (:id table)
:name field-name)]
(lib.metadata/field metadata-provider field-id)))))
(let [{:keys [checkpoint-filter checkpoint-filter-unique-key]} source-incremental-strategy]
(when-some [{column-name :name
:keys [base-type]
:as column}
(cond
checkpoint-filter-unique-key
(source->checkpoint-filter-unique-key query source-incremental-strategy)
checkpoint-filter
(when-some [field-id (t2/select-one-pk :model/Field
:table_id (:id table)
:name checkpoint-filter)]
(lib.metadata/field metadata-provider field-id)))]
(when-not (supported-incremental-filter-type? base-type)
(throw (ex-info (str "Checkpoint column '" column-name "' has unsupported type " (pr-str base-type) ". "
"Only numeric and temporal columns are supported for incremental filtering.")
{:column-name column-name
:base-type base-type})))
column)))
(defn next-checkpoint
"Build a query to compute the MAX of the checkpoint column from the target table.
@@ -641,3 +677,29 @@
(transforms.instrumentation/with-stage-timing [run-id [:import :create-incremental-filter-index]]
(log/infof "Creating secondary index %s(%s) for target %s" index-name value (pr-str target))
(driver/create-index! driver (:id database) (:schema target) (:name target) index-name [value]))))))
(mu/defn handle-transform-complete!
"Handles followup tasks for when a transform has completed.
Specifically, this syncs the target db, publishes a `:event/transform-run-complete` event, and potentially updates
the target table's index.
See [[metabase.transforms-util/decide-secondary-index-ddl]] for details on the index handling."
[& {:keys [run-id transform db]}
:- [:map
[:run-id ::transforms.schema/run-id]
[:transform ::transforms.schema/transform]
[:db [:fn {:error/message "Must a t2 database object"} #(= (t2/model %) :model/Database)]]]]
(let [target (:target transform)]
(transforms.instrumentation/with-stage-timing [run-id [:import :table-sync]]
(sync-target! target db)
;; This event must be published only after the sync is complete - the new table needs to be in AppDB.
(events/publish-event! :event/transform-run-complete
{:object {:db-id (:id db)
:transform-id (:id transform)
:transform-type (keyword (:type target))
:output-schema (:schema target)
:output-table (qualified-table-name (:engine db) target)}})
;; Creating an index after sync means the filter column is known in the appdb.
;; The index would be synced the next time sync runs, but at time of writing, index sync is disabled.
(execute-secondary-index-ddl-if-required! transform run-id db target))))

View File

@@ -377,9 +377,10 @@
ex-message-fn #(exceptional-run-message message-log %)
result (transforms.instrumentation/with-stage-timing [run-id [:computation :python-execution]]
(transforms.util/run-cancelable-transform! run-id driver transform-details run-fn :ex-message-fn ex-message-fn))]
(transforms.instrumentation/with-stage-timing [run-id [:import :table-sync]]
(transforms.util/sync-target! target db))
(transforms.util/execute-secondary-index-ddl-if-required! transform run-id db target)
(transforms.util/handle-transform-complete!
:run-id run-id
:transform transform
:db db)
{:run_id run-id
:result result}))
(catch Throwable t

View File

@@ -55,7 +55,7 @@
(delete-group :crowberto 204 false)))
(testing "if `advanced-permissions` is enabled"
(mt/with-premium-features #{:advanced-permissions}
(mt/with-premium-features #{:advanced-permissions :data-studio}
(testing "still fails if user is not a manager"
(get-groups user 403)
(get-one-group user 403 group)
@@ -210,8 +210,8 @@
:user_id (:id new-user)
:is_group_manager true})))))
(testing "Admin can could view all groups"
(is (= (t2/select-fn-set :id :model/PermissionsGroup :is_tenant_group false)
(testing "Admin can view all groups with members"
(is (= (t2/select-fn-set :group_id :model/PermissionsGroupMembership)
(membership->groups-ids (get-membership :crowberto 200))))))))))
(deftest get-users-api-test

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