Compare commits

...

33 Commits

Author SHA1 Message Date
Chris Truter
2076d909d9 Run the tests please 2026-03-16 16:23:48 +02:00
Chris Truter
b2c2816d86 Switch branch of python-runner-container 2026-03-16 16:12:41 +02:00
Nicola Mometto
d8104c911d untgz: use resolveIn to prevent zip slip (#70921) 2026-03-16 12:49:46 +00:00
Nicola Mometto
5d5930ab64 Track OSS transform usage in Snowplow (#70925)
move snowplow transform metrics to OSS, as transforms are now available on OSS
2026-03-16 12:47:28 +00:00
github-automation-metabase
2738ec81b7 Reduce number of tests kept in memory for embedding SDK component tests (#70928)
* Add memory management settings to SDK component test Cypress config

Add experimentalMemoryManagement: true and numTestsKeptInMemory: 0 to
the embedding SDK component test config. The config spreads defaultConfig
which lacks these settings, causing Cypress to retain 50 DOM snapshots
per test (the default). This leads to Chrome renderer OOM crashes during
content-translations.cy.spec.tsx.

- experimentalMemoryManagement: true triggers gc() between tests
- numTestsKeptInMemory: 0 prevents DOM snapshot heap accumulation

* Use numTestsKeptInMemory: 1 to match main CI config convention

Change from 0 to 1 per review feedback. This matches the value used in
the main e2e config (config.js:190) and keeps the last snapshot available
for debugging while still preventing OOM from the default of 50.
2026-03-16 12:35:32 +00:00
Nicola Mometto
cd77eb5140 Skip running transform jobs if there are no transforms to run (#70926)
* fix: don't run transform jobs if there are no tranforms to run

* chore: add test
2026-03-16 12:34:46 +00:00
Kevin Kelleher
2a4046736a Fix pivot table drill through when binning a datetime by day (#70680) 2026-03-16 07:29:47 -04:00
Ngoc Khuat
660c1ddfe6 Fix flaky scoring-test/mine-test on H2 (#70852) 2026-03-16 17:48:53 +07:00
Ngoc Khuat
6845e78a5d Fix flaky schema-change integration test on Redshift (#70863)
Fix flaky python-transform-schema-change-integration-test on Redshift

Replace fixed Thread/sleep with polling for correct field metadata.
The transform pipeline sets run status to :succeeded before
complete-execution! (which runs the metadata sync) finishes. On
Redshift, the sync takes longer due to network latency, so the
2-second sleep was insufficient — the QP would query with stale
Field records (still referencing the old "age" column).
2026-03-16 17:48:27 +07:00
github-automation-metabase
eea9a750d6 Add 403 error handling to rerun-workflows.yml (#70916)
Handle 403 errors in rerun-workflows.yml

Wrap the reRunWorkflowFailedJobs API call in a try/catch that
gracefully handles HTTP 403 errors. This prevents false-positive CI
failures when the target run is cancelled, a PR run, or otherwise
ineligible for retry.

Also adds missing await to the API call so errors are properly caught.
2026-03-16 08:42:34 +01:00
metabase-bot[bot]
123a4f966d [chore] Update E2E test timings with recent data (#70913)
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-03-16 07:02:38 +01:00
Denis Berezin
659d0a37b1 Add side-panel to Tables and Metrics in the Library (#69673)
* Temp

* Add full implementation

* Add e2e tests

* Remove excess image file

* Temp

* Address design issues

* Fix design review comments

* One more fix

* Fix e2e tests

* Remove empty source gotcha

* Address review issues

* Revert graphy changes
2026-03-14 02:22:45 +03:00
Eric Normand
59a52b2631 Validate FK target and dimension remap fields belong to the same database (#70879)
- Adds same-database validation for `fk_target_field_id` in `PUT /api/field/:id` (resolves existing TODO)
- Adds existence and same-database validation for `human_readable_field_id` in `POST /api/field/:id/dimension`
- Returns 400 if the target field belongs to a different database than the source field
2026-03-13 22:22:20 +00:00
bryan
a83196324b Fix MySQL 8.4 app-db test failures in dependencies tests (#70897) 2026-03-13 16:11:42 -06:00
Robert Kosara
9ab2bfbae8 Update HAR file creation instructions to include sensitive data (#70394)
Updated the documentation to clarify that the HAR file contains sensitive data and revised the instructions for saving the HAR file accordingly. Also added a screenshot of the settings in Chrome.

---------

Co-authored-by: Rodrigo López Dato <rodrigo@metabase.com>
2026-03-13 14:28:16 -07:00
Eric Normand
c261122999 Fix broken deps not surfaced for missing fields in :fields (GHY-3157) (#70865)
- The `:soft?` marking in `find-bad-refs-with-source` was too broad — it marked **all** bad refs in `:fields` clauses as soft, even when the field was completely missing from the source (not just inactive).
- Added `(not (:active column true))` guard so only **inactive** fields in `:fields` are marked `:soft?`. Missing/unresolvable fields remain hard errors and are properly surfaced by dependency analysis.
- This preserves the GHY-3044 fix (inactive fields the QP can drop are still soft) while fixing GHY-3157 (genuinely missing fields are reported).
2026-03-13 21:03:54 +00:00
Denis Berezin
0b65ccce3a UXW-3177 batch dashcard queries by 5 (#70542)
* UXW-3177 batch dashcard queries by 5

* Add unit tests

* Remove fetch limit for HTTP2+
2026-03-13 23:44:45 +03:00
Nathan Voxland
bb2adc5f46 Changed repro-bot label to ".Run Repro Bot" (#70890) 2026-03-13 15:37:09 -05:00
Ben Grabow
c5dcdc3238 Temporarily disable app db version checks (#70884)
* Temporarily disable app db version checks

* Comment out unused code

* Entirely rip out unused code
2026-03-13 12:47:04 -07:00
Aleksandr Lesnenko
5537f0f258 Add cypress claude setup (#70715)
e2e skills
2026-03-13 15:06:50 -04:00
Riley Thompson
bfdd8a0b23 Handle clickhouse dbname when it has spaces in it (#70822)
* take first name

* Add test

* fix test

* Add comment

* fix typos

Fix typo in comment regarding legacy `dbname` handling.
2026-03-13 18:47:35 +00:00
Riley Thompson
3c05a30b8e Enable old clickhouse tests (#70756)
* enable clickhouse tests

* enable more tests

* fix kondo and test
2026-03-13 18:22:20 +00:00
Alex Yarosh
106a707fcc docs: it's writable not writeable (#70877)
* it's writable

* no md in redirect dummy
2026-03-13 14:14:52 -04:00
Nathan Voxland
21c8136e7e Repro Bot Enabling (#70823) 2026-03-13 13:10:22 -05:00
John Swanson
7319599ceb Fix token check errors on startup (#70789)
During startup, the database isn't migrated yet, so we can't check the
token hash.

If the database hasn't been started yet, skip checking the token hash in
the DB and just delegate to the lower-level checker that actually hits
the store.
2026-03-13 08:46:20 -08:00
Nathan Voxland
13bc693357 Can't use regex filter with \b on non-ascii names (#70838) 2026-03-13 11:36:45 -05:00
Nick Fitzpatrick
2b11a3bed2 Update react-grid-layout and react-resizeable (#70760)
* Update react-grid-layout and react-resizeable

* adding e2e test

* e2e adjustment
2026-03-13 13:20:12 -03:00
Nick Fitzpatrick
c910ca3fd3 measure table names in tree in v1 data model page (#70657) 2026-03-13 12:23:45 -03:00
bryan
d8b40292d1 Disable blocking dependency checks on save (#70819) 2026-03-13 15:18:49 +00:00
Nick Fitzpatrick
b773f68f23 update html2canvas-pro (#70728)
* update html2canvas

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-03-13 12:13:20 -03:00
Edward Paget
647861b624 test: agent-specific test runner (#70821)
* test: agent-specific test runner

Adds an "agent" test runner that filters the output of running `clojure
-X:...:test` commands to make it more relevant for agents preventing
them from needing to use tail or grep to find information and reducing
context pollution.

Also implements randomized ports for test web-servers allowing multiple
agents to run tests in parallel (or to run multiple commands in
parallel). This removes the directive to use the ./bin/mage-based test
runner, to direct agents to use the agent-specific runner.

* fixup! missing import

* add the test agent script
2026-03-13 10:00:49 -05:00
lbrdnk
e741639713 Error source for native entities (#70021)
* Update schema with unknown error source

* Adjust validate-native-query

* Update tests

* Extend error source to multi table queries

* Update test

* Sql-tools providing more functions

* Card references

* Update tests

* require

* Unrelated fix

* repl reloading

* Tests

* Use placeholders instead of dummy subqueries

* Comment

* Placeholders

* Modules (temporary)

* potemkin

* Address circular deps extend sql-tools interface

* Sort modules

* Fix params

* Adjust test

* Placeholder

* More tests

* Cleanup

* whitespace

* Remove explicit unknown for now

* typo

* Update tests

* Break the circular dep more standard way

* Add comment

* whitespace
2026-03-13 16:00:06 +01:00
Anton Kostenko
5765b0df15 Add OpenTelemetry Tracing module (#69295)
* Add OpenTelemetry Tracing module

---------

Co-authored-by: dan sutton <dan@dpsutton.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-03-13 10:48:13 -04:00
191 changed files with 6556 additions and 2491 deletions

View File

@@ -12,14 +12,7 @@
## Testing
- **Run a test:** `./bin/mage run-tests namespace/test-name`
- **Run all tests in a namespace:** `./bin/mage run-tests namespace`
- **Run all tests for a module:** `./bin/mage run-tests test/metabase/notification` Because the module lives in that directory.
Note: the `./bin/mage run-tests` command accepts multiple args, so you can pass
`./bin/mage run-tests namespace/test-name namespace/other-test namespace/third-test`
to run 3 tests, or
`./bin/mage run-tests test/metabase/module1 test/metabase/module2` to run 2 modules.
Use `./bin/test-agent` to run backend tests. See the top-level CLAUDE.md for usage.
## Code Readability

View File

@@ -0,0 +1,357 @@
--b-
name: add-tracing
description: Add OpenTelemetry tracing spans to Clojure code following Metabase tracing conventions. Use when instrumenting backend code with trace coverage.
---
# Add Tracing Spans to Clojure Code
This skill helps you add OpenTelemetry (OTel) tracing spans to the Metabase backend codebase using the custom `tracing/with-span` macro.
## Reference Files
- `src/metabase/tracing/core.clj` - `with-span` macro, group registry, SDK lifecycle, `best-effort-sanitize-sql`, Pyroscope integration
- `src/metabase/task/impl.clj` - `defjob` macro that wraps Quartz jobs with root spans
- `.clj-kondo/config/modules/config.edn` - Module boundary configuration
## Module Architecture
The tracing module has a deliberately minimal API surface. **Only 2 namespaces are public** (listed in `:api` in the module config):
| Namespace | Role | Status |
|---|---|---|
| `tracing.core` | Primary API: `with-span`, groups, SDK lifecycle, Pyroscope, MDC, `best-effort-sanitize-sql` | **Public API** |
| `tracing.init` | Side-effect loader for `quartz` and `settings` | **Public API** (init convention) |
| `tracing.attributes` | `best-effort-sanitize-sql` implementation (re-exported via `tracing.core`) | Internal |
| `tracing.settings` | Setting definitions (`MB_TRACING_*` env vars) | Internal |
| `tracing.quartz` | Quartz JDBC proxy + JobListener | Internal |
**Rules:**
- Only require `[metabase.tracing.core :as tracing]` from outside the module. `tracing/best-effort-sanitize-sql` and all other public functions are available from this single namespace.
- Do not add new API namespaces. Add new public functions to `tracing.core` instead.
- Do not require internal namespaces (`tracing.attributes`, `tracing.settings`, `tracing.quartz`) from outside the module.
- `:uses :any` on the `core` module does NOT bypass the target module's `:api` check — internal namespaces are still enforced.
### Cyclic Dependency Avoidance
`tracing/core.clj` is required by many modules across the codebase. It **must NOT** compile-time require `tracing.settings`, as this creates transitive cyclic load dependencies (e.g., `settings/core -> tracing/settings -> tracing/core -> events/impl -> events/core`).
Instead, `tracing/core.clj` uses `requiring-resolve` for settings access:
```clojure
;; CORRECT — lazy runtime resolution, no compile-time dependency
((requiring-resolve 'metabase.tracing.settings/tracing-enabled))
;; WRONG — creates cyclic load dependency
(require '[metabase.tracing.settings :as settings])
(settings/tracing-enabled)
```
External library namespaces (clj-otel API, SDK, exporters) are safe to require normally — they don't participate in Metabase namespace cycles.
**Important:** `requiring-resolve` must use **literal quoted symbols**. Kondo hooks validate that `required-namespaces` are all simple symbols, so dynamic construction fails:
```clojure
;; CORRECT — literal quoted symbol
(requiring-resolve 'metabase.tracing.settings/tracing-endpoint)
;; WRONG — kondo hook rejects this: "Assert failed: (every? simple-symbol? required-namespaces)"
(requiring-resolve (symbol "metabase.tracing.settings" "tracing-endpoint"))
```
## Quick Checklist
When adding tracing spans:
- [ ] Module has `tracing` in its `:uses` set in `.clj-kondo/config/modules/config.edn`
- [ ] Added `[metabase.tracing.core :as tracing]` to ns requires (alphabetically sorted)
- [ ] Span wraps a meaningful I/O boundary (not pure computation)
- [ ] Group matches the domain (check `src/metabase/tracing/core.clj` for registered groups; add a new one if none fit)
- [ ] Span name follows dot-notation convention (`"domain.subsystem.operation"`)
- [ ] Attributes use namespaced keywords (`:search/query-length`, `:db/id`)
- [ ] No sensitive data in attributes (use `best-effort-sanitize-sql` for HoneySQL, never raw SQL)
- [ ] No new tracing namespaces created (add to `tracing.core` instead)
- [ ] No `DO_NOT_ADD_NEW_FILES_HERE.txt` violations in the target directory
- [ ] Run `clj-kondo --lint <files>` to verify 0 errors, 0 warnings
- [ ] Add or update tests in the corresponding `test/` path (see Testing section below)
- [ ] Run tests: `clojure -X:dev:test :only <test-ns>`
## The `with-span` Macro
```clojure
(tracing/with-span group span-name attrs & body)
```
- **group** - A keyword selecting which trace group this span belongs to (e.g., `:tasks`, `:sync`)
- **span-name** - A string identifying the span in traces (e.g., `"search.execute"`)
- **attrs** - A map of span attributes (e.g., `{:db/id 42}`)
- **body** - The code to execute inside the span
**When disabled:** zero overhead -- single atom deref + boolean check, body runs directly.
**When enabled:** creates OTel span AND injects `trace_id`/`span_id` into Log4j2 MDC for log-to-trace correlation.
## Trace Groups
Groups are registered in `src/metabase/tracing/core.clj`. Check that file for the current list. The general rule: **match the group to the domain, not the call site.** If code runs inside a Quartz job but is logically search work, use `:search`, not `:tasks`.
To add a new group:
```clojure
;; In src/metabase/tracing/core.clj
(register-group! :my-domain "Description of what this covers")
```
Users enable groups via `MB_TRACING_GROUPS=tasks,search,sync` (comma-separated, or `"all"`).
## Naming Conventions
### Span Names
Use dot-separated hierarchical names: `"domain.subsystem.operation"`. The domain prefix should match the group name:
```
search.execute -- `:search` group
sync.fingerprint.table -- `:sync` group
task.session-cleanup.delete -- `:tasks` group
db-app.collection-items -- `:db-app` group
```
### Attributes
Use namespaced keywords. The namespace groups related attributes:
```clojure
:db/id -- Database ID (integer)
:db/engine -- Database engine name (string)
:db/statement -- Sanitized SQL (string, via best-effort-sanitize-sql)
:search/engine -- Search engine name (string)
:search/query-length -- Query string length (integer)
:sync/table -- Table name (string)
:sync/step -- Sync step name (string)
:task/name -- Task name (string)
:http/method -- HTTP method (string)
:http/url -- Request URL (string)
```
Invent new namespaced attributes as needed (e.g., `:pulse/id`, `:transform/count`). Keep values as primitives (strings, numbers, booleans) -- no maps or collections.
## Step-by-Step: Adding a Span
### 1. Check module boundaries
Look up the module for your namespace in `.clj-kondo/config/modules/config.edn`. If `tracing` is not in the module's `:uses` set, add it (keep alphabetically sorted):
```edn
my-module
{:team "MyTeam"
:uses #{analytics config tracing util}}
```
### 2. Add the require
```clojure
(ns metabase.my-module.thing
(:require
[metabase.tracing.core :as tracing]
[metabase.util :as u]))
```
`best-effort-sanitize-sql` is available from `tracing.core` — no additional require needed.
### 3. Identify the I/O boundary
Only wrap code at **meaningful I/O boundaries**:
**DO trace:**
- External API calls (embedding APIs, metabot, webhooks)
- Database queries (both app DB and user DB)
- Network requests (HTTP calls to external services)
- Heavy batch processing (batch indexing, batch embedding)
- Top-level orchestration functions that coordinate multiple sub-operations
**DO NOT trace:**
- Pure computation (sorting, filtering, mapping)
- Simple single-row lookups (`t2/select-one :model/Setting :key k`)
- Every function in a call chain (only boundaries matter)
- Trivial operations (string formatting, hash calculations)
### 4. Wrap with `with-span`
```clojure
;; Simple span (no attributes needed)
(tracing/with-span :search "search.init-index" {}
(do-expensive-thing))
;; Span with static attributes
(tracing/with-span :sync "sync.fingerprint.table"
{:db/id (:db_id table)
:sync/table (:name table)}
(fingerprint-fields! table fields))
;; Span with computed attributes
(tracing/with-span :search "search.execute"
{:search/engine (name (:search-engine ctx))
:search/query-length (count (:search-string ctx))}
(search.engine/results ctx))
;; Span with sanitized SQL (for dynamic HoneySQL queries)
(let [hsql {:delete-from [(t2/table-name :model/Session)]
:where [:< :created_at oldest-allowed]}]
(tracing/with-span :tasks "task.session-cleanup.delete"
{:db/statement (tracing/best-effort-sanitize-sql hsql)}
(t2/query-one hsql)))
;; Sub-spans breaking a function into I/O phases
(let [embedding (tracing/with-span :search "search.semantic.embedding"
{:search.semantic/provider (:provider model)}
(get-embedding model search-string))
results (tracing/with-span :search "search.semantic.db-query" {}
(into [] xform reducible))]
(process results))
;; Per-item iteration spans
(doseq [e (search.engine/active-engines)]
(tracing/with-span :search "search.ingestion.update" {:search/engine (name e)}
(search.engine/update! e batch)))
```
### 5. Add tests
Create or update tests in the corresponding `test/` path. Follow the patterns in existing tracing tests:
- **Reference tests:** `test/metabase/tracing/quartz_test.clj`, `test/metabase/server/middleware/trace_test.clj`
- Use `tracing/init-enabled-groups!` / `tracing/shutdown-groups!` with `try`/`finally` to manage group lifecycle
- Test both enabled and disabled paths (verify zero overhead when group is off)
- Use `reify` mocks for Java interfaces (Connection, PreparedStatement, JobListener, etc.)
- Add `(set! *warn-on-reflection* true)` and type-hint proxy/reify calls to avoid reflection warnings
```clojure
(deftest my-span-enabled-test
(testing "when group is enabled, span is created"
(try
(tracing/init-enabled-groups! "my-group" "INFO")
;; ... test that span behavior occurs ...
(finally
(tracing/shutdown-groups!)))))
(deftest my-span-disabled-test
(testing "when group is disabled, code runs without tracing"
(tracing/shutdown-groups!)
;; ... test that code still works, no wrapping applied ...
))
```
### 6. Lint and run tests
```bash
# Lint modified source and test files — expect 0 errors, 0 warnings
clj-kondo --lint path/to/modified/file.clj path/to/test/file.clj
# Run tests (requires Java 21+)
clojure -X:dev:test :only my-ns.test-ns
```
Expect: all tests pass, 0 failures, 0 errors, no reflection warnings from your files.
## Sanitizing SQL for Attributes
When including SQL in span attributes, **always** use `tracing/best-effort-sanitize-sql`. This converts HoneySQL maps to parameterized SQL strings where values become `?` placeholders -- no data leaks.
```clojure
(let [hsql {:delete-from [:core_session]
:where [:< :created_at some-timestamp]}]
(tracing/with-span :tasks "task.cleanup.delete"
{:db/statement (tracing/best-effort-sanitize-sql hsql)}
(t2/query-one hsql)))
;; Trace attribute: db/statement = "DELETE FROM core_session WHERE created_at < ?"
```
**Rules:**
- Never put raw SQL strings or user-provided values in attributes
- Use `best-effort-sanitize-sql` only for app DB (HoneySQL) queries
- For external/user DB queries, trace only timing and counts, not SQL content
## Defjob and Root Spans
The `defjob` macro in `metabase.task.impl` automatically wraps every Quartz job with a `:tasks` root span:
```clojure
(task/defjob ^{DisallowConcurrentExecution true} SessionCleanup [_]
(cleanup-sessions!))
;; Automatically creates span: "task.SessionCleanup" {:task/name "SessionCleanup"}
```
You do NOT need a root span inside `defjob` bodies. Add **child spans** for I/O inside the job.
For code on plain `Thread`s (not Quartz), add the root span manually:
```clojure
(defn init! []
(tracing/with-span :search "search.task.init" {}
(search/init-index!)))
```
## What NOT to Do
### Span Usage Mistakes
```clojure
;; WRONG - pure computation, no I/O
(tracing/with-span :search "search.format-results" {}
(map format-result results))
;; WRONG - trivial single-row lookup
(tracing/with-span :db-app "db-app.get-setting" {}
(t2/select-one :model/Setting :key "my-setting"))
;; WRONG - raw SQL in attributes (data leak)
(tracing/with-span :tasks "task.cleanup" {:db/statement raw-sql-string}
(execute! raw-sql-string))
;; WRONG - wrong group (search work should use :search, not :tasks)
(tracing/with-span :tasks "search.execute" {} ...)
;; WRONG - redundant nesting (do-search already has a span)
(tracing/with-span :search "search.process" {}
(let [results (do-search ctx)]
(tracing/with-span :search "search.format" {}
(format-results results))))
```
### Architecture Mistakes
```clojure
;; WRONG - creating a new tracing namespace
(ns metabase.tracing.my-feature ...)
;; WRONG - requiring internal tracing namespaces from outside the module
(ns metabase.my-module.thing
(:require [metabase.tracing.attributes :as trace-attrs] ;; internal!
[metabase.tracing.settings :as tracing.settings] ;; internal!
[metabase.tracing.quartz :as tracing.quartz])) ;; internal!
;; WRONG - adding compile-time requires to tracing/core.clj for settings or SDK
;; This creates cyclic load dependencies because tracing/core is widely required
(ns metabase.tracing.core
(:require [metabase.tracing.settings :as settings])) ;; causes cycle!
;; WRONG - dynamic symbol construction with requiring-resolve (kondo rejects it)
(requiring-resolve (symbol "metabase.tracing.settings" "tracing-enabled"))
```
## Configuration
All settings are env-var-only (defined in `src/metabase/tracing/settings.clj`):
```bash
# Core
MB_TRACING_ENABLED=true # Enable tracing (default: false)
MB_TRACING_ENDPOINT=host:4317 # OTLP collector endpoint (default: http://localhost:4317)
MB_TRACING_GROUPS=tasks,search,sync # Comma-separated groups or "all" (default: all)
MB_TRACING_SERVICE_NAME=metabase # Service name in traces (default: hostname)
MB_TRACING_LOG_LEVEL=DEBUG # Log threshold for traced threads: TRACE/DEBUG/INFO (default: INFO)
# Batch span processor tuning
MB_TRACING_MAX_QUEUE_SIZE=2048 # Max spans queued for export; drops when full (default: 2048)
MB_TRACING_EXPORT_TIMEOUT_MS=10000 # Max wait for batch export to complete (default: 10000)
MB_TRACING_SCHEDULE_DELAY_MS=5000 # Delay between consecutive batch exports (default: 5000)
```

View File

@@ -0,0 +1,260 @@
---
name: e2e-test-create
description: >
Analyze React component source code to understand UI structure,
then generate idiomatic Cypress E2E tests following Metabase conventions.
Falls back to Playwright MCP browser exploration only when code reading
and screenshot debugging are insufficient.
disable-model-invocation: true
allowed-tools:
- Bash
- Read
- Write
- Grep
- Glob
- Skill
- "mcp__playwright__*"
---
# Code-Reading-First → Generate Cypress Tests (Metabase)
You are writing Cypress E2E tests for the **Metabase** codebase.
Before generating ANY test code, you MUST analyze React component source code
to understand DOM structure, selectors, and user flows.
## Phase 0 — Research
1. Read existing helpers before writing anything:
- `e2e/support/helpers/` — all shared helpers (restore, signInAs, openOrdersTable, etc.)
- `e2e/support/cypress_sample_database.ts` — table/field schema constants (ORDERS, PRODUCTS, etc.)
- `e2e/support/cypress_sample_instance_data.ts` — instance-specific IDs (ORDERS_DASHBOARD_ID, NORMAL_USER_ID, etc.)
2. Glob `e2e/test/scenarios/` to find the closest existing spec to the area under test.
Study its patterns — match them exactly.
3. Glob `frontend/src/metabase/` to find React components for the feature area.
## Phase 1 — Code Analysis
Read React component source to understand DOM structure. No browser needed — source code has everything.
1. **Find relevant components**: Glob and grep `frontend/src/metabase/` for the feature area.
2. **Extract selectors**: Grep for `data-testid` in relevant components.
3. **Note visible text**: Read component JSX for button labels, headings, placeholders.
4. **Note aria attributes**: Grep for `aria-label` in relevant components.
5. **Understand user flows**: Read event handlers (onClick, onSubmit, onChange) to understand interactions.
6. **Find API calls**: Grep for `Api.use`, `fetch`, `useQuery`, endpoint definitions to identify API calls to intercept.
7. **Cross-reference with existing specs**: Find specs in the same area and reuse their proven selectors and `cy.intercept` patterns.
## Phase 2 — Start Backend
Use `MB_EDITION=oss` by default. Only use `MB_EDITION=ee` when the user explicitly asks to write an enterprise test.
Start the backend using `run_in_background: true` (NOT `&`).
`bin/e2e-backend` automatically detects if a backend is already running and reuses it.
```bash
MB_EDITION=oss bin/e2e-backend
```
Do NOT manually generate snapshots by running unrelated test specs.
The `bun test-cypress` runner has `GENERATE_SNAPSHOTS: true` by default and automatically
generates snapshots before running any spec. When running tests in Phase 4 via the `/e2e-test` skill,
snapshots will be generated on the first run if they don't already exist.
Restore clean test data:
```bash
curl -sf -X POST http://localhost:4000/api/testing/restore/default
```
## Phase 3 — Generate Cypress Spec
### File location & naming
Place specs in `e2e/test/scenarios/<area>/` mirroring the URL structure.
Name: `<feature>.cy.spec.js` (NOT `.cy.spec.ts`).
### Metabase conventions (mandatory)
- **Helpers via `cy.H`**: All helpers are accessed via `const { H } = cy;` — NOT via direct imports from `e2e/support/helpers`.
```js
const { H } = cy;
describe("feature name", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
});
```
- **Sample Database schema** (table/field definitions): Import from `cypress_sample_database`.
```js
import { ORDERS, ORDERS_ID, PRODUCTS } from "e2e/support/cypress_sample_database";
```
- **Instance data** (dashboard IDs, user IDs, etc.): Import from `cypress_sample_instance_data`.
```js
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
```
- **Selectors** (priority order):
1. `cy.findByText()` / `cy.findByLabelText()` / `cy.findByRole()` — from `@testing-library/cypress`
2. `cy.findByTestId()` — for `data-testid` attributes
3. `cy.get("[data-testid='...']")` — fallback
4. NEVER use positional selectors, CSS class names, or XPaths.
- **Navigation helpers**: Use existing helpers like `H.openOrdersTable()`, `H.openNativeEditor()`,
`H.visitDashboard(id)`, `H.visitQuestion(id)` instead of raw `cy.visit()` chains.
Grep `e2e/support/helpers/` to discover what's available for your area.
- **API setup over UI setup**: Use `cy.request()` or existing API helpers to set up state.
Only use the UI for the flow you're actually testing.
- **Assertions**: Assert on visible text, URL, aria state — not DOM structure.
- **Waits**: Never use `cy.wait(ms)`. Use `cy.intercept()` + `cy.wait("@alias")` for API calls,
or `cy.findByText().should("be.visible")` for DOM readiness.
- **Isolation**: Each `it()` block must be independently runnable.
Don't depend on state from a previous `it()`.
### Spec structure template
```js
const { H } = cy;
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
import { ORDERS, ORDERS_ID } from "e2e/support/cypress_sample_database";
describe("area > sub-area > feature (#issue-number)", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("should do the primary happy-path thing", () => {
// test
});
it("should handle the edge case", () => {
// test
});
});
```
### Intercepts
When you identified API calls during code analysis, stub or wait on them:
```js
cy.intercept("POST", "/api/dataset").as("dataset");
// ... trigger action ...
cy.wait("@dataset");
```
## Phase 4 — Validate
After generating specs:
1. Check that all imported helpers exist (Grep `e2e/support/helpers/`).
2. **You MUST use the `/e2e-test` skill** to run tests — do NOT run `bun test-cypress` directly.
The `/e2e-test` skill handles edition selection, snapshot management, and correct env vars.
`/e2e-test GREP="should do the thing" --spec e2e/test/scenarios/<path>`
If you created multiple `it()` blocks, run each one individually to isolate failures.
## Phase 5 — Fix Failures (up to 2 attempts)
When a test fails, **try to fix it from Cypress output first**:
1. Read the failure screenshot (path printed under `(Screenshots)`).
2. Read the error message and code frame from the console output.
3. Fix the test and re-run (back to Phase 4, step 2).
If you cannot diagnose the issue after 2 attempts, proceed to Phase 6.
## Phase 6 — Playwright Fallback
Only reach this phase after 2 failed fix attempts from Phase 5. The backend is already running.
Restore clean test data:
```bash
curl -sf -X POST http://localhost:4000/api/testing/restore/default
```
**Bypass CSP** headers before navigating (Metabase serves strict CSP that blocks dev server scripts).
Use `browser_run_code` to set this up:
```js
async (page) => {
// Strip CSP headers so the page loads (mirrors Cypress chromeWebSecurity: false)
await page.context().route('**/*', async (route) => {
const response = await route.fetch();
const headers = { ...response.headers() };
delete headers['content-security-policy'];
delete headers['content-security-policy-report-only'];
await route.fulfill({ response, headers });
});
// Sign in via API
const response = await page.request.post('http://localhost:4000/api/session', {
data: { username: 'admin@metabase.test', password: '12341234' }
});
const session = await response.json();
await page.context().addCookies([{
name: 'metabase.DEVICE',
value: session.id,
domain: 'localhost',
path: '/'
}]);
await page.goto('http://localhost:4000');
await page.waitForLoadState('networkidle');
return 'signed in';
}
```
**Maintain an observation log incrementally.** After EVERY significant Playwright interaction,
IMMEDIATELY append what you observed to the scratch file BEFORE performing the next interaction:
```bash
cat >> /tmp/e2e-observations.md << 'OBSERVATION'
## [Page/Flow name]
- URL: /question/notebook#...
- Clicked: "Box plot" button → visible text "Box plot", role: radio
- Selectors: data-testid="viz-type-button", findByText("Box plot")
- API call: POST /api/dataset (triggered on viz change)
- Key state: after selecting viz type, summary sidebar shows metric picker
OBSERVATION
```
For each page/flow:
- Take an accessibility snapshot (`browser_snapshot`).
- Click through interactive elements, fill forms, trigger modals.
- **Append to the observation log immediately after each step**: URLs, visible text, aria labels, `data-testid` attrs, API calls.
- Screenshot key states.
After exploration:
1. Read back your observation log: `cat /tmp/e2e-observations.md`
2. Fix the test using observed selectors and behavior.
3. Re-run the test (back to Phase 4).
4. Clean up: `rm -f /tmp/e2e-observations.md`
## Phase 7 — Cleanup
After all tests pass (or after giving up on fixing failures), **always kill the backend on port 4000**:
```bash
lsof -ti:4000 | xargs kill 2>/dev/null || true
```
Do NOT use broad `pkill` patterns — there may be other Metabase instances on different ports.
The backend process started in Phase 2 will NOT be killed automatically when the Claude session ends.
Leaving it running wastes resources and can interfere with future sessions. Always clean up.
## What NOT to do
- Do NOT use Playwright as the first step — always analyze source code first.
- Do NOT kill the backend between phases — it stays running throughout.
- Do NOT invent selectors you didn't find in source code or observe in the browser.
- Do NOT hardcode database IDs — import from `cypress_sample_database` or `cypress_sample_instance_data`.
- Do NOT use `cy.wait(1000)` or any numeric wait.
- Do NOT create setup flows through the UI when an API helper exists.
- Do NOT put tests in `cypress/e2e/` — Metabase uses `e2e/test/scenarios/`.
- Do NOT import helpers directly from `e2e/support/helpers` — use `const { H } = cy;`.

View File

@@ -0,0 +1,78 @@
---
name: e2e-test
description: Run Cypress E2E tests, analyze failures including screenshots, and stress test for flakiness
disable-model-invocation: false
---
Run Cypress E2E test: $ARGUMENTS
## Edition selection
**Default: `MB_EDITION=oss`** — runs without enterprise tokens, simpler and faster.
Only use `MB_EDITION=ee` when the user explicitly asks to write or run an enterprise test.
**For `MB_EDITION=ee`:**
These tokens must be exported in the shell before starting Claude Code:
- `CYPRESS_MB_ALL_FEATURES_TOKEN` — all features (most EE tests need this)
- `CYPRESS_MB_PRO_SELF_HOSTED_TOKEN` — pro self-hosted (permissions, SSO, sandboxing)
- `CYPRESS_MB_STARTER_CLOUD_TOKEN` — starter plan (rarely needed)
- `CYPRESS_MB_PRO_CLOUD_TOKEN` — pro cloud (rarely needed)
**NEVER echo, print, or log token values. Only check if they are set:**
```bash
echo "ALL_FEATURES: ${CYPRESS_MB_ALL_FEATURES_TOKEN:+set}" && echo "PRO_SELF_HOSTED: ${CYPRESS_MB_PRO_SELF_HOSTED_TOKEN:+set}" && echo "STARTER: ${CYPRESS_MB_STARTER_CLOUD_TOKEN:+set}" && echo "PRO_CLOUD: ${CYPRESS_MB_PRO_CLOUD_TOKEN:+set}"
```
If tokens are missing, tell the user: "EE tokens are not set. Either export them and restart Claude Code, or add `MB_EDITION=oss` to run OSS-only tests."
## Running
First check if snapshots exist:
```bash
ls e2e/snapshots/default.sql 2>/dev/null && echo "snapshots exist" || echo "snapshots missing"
```
If snapshots exist, skip regeneration for speed:
```bash
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress $ARGUMENTS
```
If snapshots are missing (first run), let the runner generate them:
```bash
MB_EDITION=oss CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false bun test-cypress $ARGUMENTS
```
When running enterprise tests, replace `MB_EDITION=oss` with `MB_EDITION=ee` in the commands above.
If `$ARGUMENTS` is empty, ask the user which spec to run.
If the user provides a test name or fuzzy name instead of a spec path, find the spec file:
```bash
find e2e/test/scenarios -name "*FUZZY_NAME*.cy.spec.*" -type f
```
Then run with `--spec "path/to/matched.cy.spec.js"`.
To filter by test name, use the `GREP` env var (not `--env grep=`, which breaks on commas):
```bash
GREP="test name here" MB_EDITION=oss bun test-cypress --spec path/to/spec.js
```
## Stress test (flaky detection)
When the user asks to verify a test is not flaky:
```bash
MB_EDITION=oss bin/e2e-stress-test $ARGUMENTS
```
Set `E2E_STRESS_RUNS=N` for more iterations (default: 5). Stops on first failure and prints screenshot paths. Read those screenshots to analyze the failure.
## Analyzing failures
When tests fail:
1. Read the **screenshot** for each failure — paths are printed in Cypress output under `(Screenshots)`, typically at `cypress/screenshots/<spec-name>/<test-name> (failed).png`
2. Read the error message and code frame from the console output
3. Identify the root cause: is it a test bug, a product bug, or a timing issue?
4. Suggest a fix with specific code changes

View File

@@ -215,6 +215,7 @@
settings
task
task-history
tracing
util}}
auth-identity
@@ -397,6 +398,7 @@
request
revisions
settings
tracing
transforms
upload
util}}
@@ -589,6 +591,7 @@
settings
sql-tools
system
tracing
util
warehouse-schema
warehouses}}
@@ -667,7 +670,8 @@
:api #{metabase.events.core
metabase.events.init}
:uses #{models
util}}
util}
:friends #{tracing}}
formatter
{:team "UX West"
@@ -891,6 +895,7 @@
system
task
task-history
tracing
util}}
models
@@ -964,6 +969,7 @@
system
task
task-history
tracing
users
util}}
@@ -1007,6 +1013,7 @@
premium-features
remote-sync
settings
tracing
util
enterprise/advanced-permissions}}
@@ -1052,6 +1059,7 @@
settings
startup
task
tracing
util}}
product-feedback
@@ -1119,6 +1127,7 @@
request
task
task-history
tracing
util
enterprise/advanced-permissions
enterprise/sandbox}}
@@ -1262,6 +1271,7 @@
server
settings
system
tracing
users
util}}
@@ -1343,6 +1353,7 @@
settings
startup
task
tracing
transforms
util}}
@@ -1389,6 +1400,7 @@
session
settings
system
tracing
users
util
enterprise/sso}}
@@ -1410,6 +1422,7 @@
sso
system
task
tracing
util}}
settings
@@ -1532,6 +1545,7 @@
settings
task
task-history
tracing
util
warehouse-schema
warehouses}}
@@ -1548,6 +1562,7 @@
metabase.task.core}
:uses #{app-db
classloader
tracing
util}}
task-history
@@ -1564,6 +1579,7 @@
query-processor
request
task
tracing
util}}
tenants
@@ -1611,6 +1627,15 @@
models
util}}
tracing
{:team "DevEx"
:api #{metabase.tracing.core
metabase.tracing.init}
:uses #{events
settings
task
util}}
transforms
{:team "Querying"
:api #{metabase.transforms.core
@@ -1633,6 +1658,7 @@
settings
task
task-history
tracing
transforms-base
util}
:friends #{models
@@ -2194,6 +2220,7 @@
request
revisions
settings
sql-tools
task
transforms
transforms-base
@@ -2434,6 +2461,7 @@
search
settings
task
tracing
util
enterprise/llm
enterprise/metabot-v3}}

View File

@@ -19,7 +19,7 @@ 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 --depth 1 -b extensible-resource-schema https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
if [ "$SKIP_START" = false ]; then
echo "Starting python-runner..."

View File

@@ -10,6 +10,11 @@ on:
description: "Issue number to reproduce"
required: true
type: string
repro_bot_branch:
description: "Branch of repro-bot to use"
required: false
default: "main"
type: string
jobs:
repro-bot:
@@ -21,7 +26,7 @@ jobs:
id-token: write
if: |
(github.event_name == 'push') ||
(github.event_name == 'issues' && github.event.label.name == 'repro-bot' && github.event.issue.state == 'open') ||
(github.event_name == 'issues' && github.event.label.name == '.Run Repro Bot' && github.event.issue.state == 'open') ||
(github.event_name == 'workflow_dispatch')
steps:
@@ -36,48 +41,100 @@ jobs:
echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
fi
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare-backend
- uses: ./.github/actions/prepare-frontend
- name: Checkout repro-bot
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: metabase/repro-bot
ref: ci-support
ref: ${{ github.event.inputs.repro_bot_branch || 'main' }}
token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
path: repro-bot
- name: Pre-clone metabase bare git repo
run: git clone --bare https://github.com/metabase/metabase.git git/metabase
run: git clone --bare .git repro-bot/git/metabase
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Set up Python (uv)
uses: astral-sh/setup-uv@v4
- name: Capture Node.js path for MCP subprocesses
run: echo "NODE_BIN_DIR=$(dirname $(which node))" >> $GITHUB_ENV
- name: Install playwright-cli
run: |
npm install -g @playwright/cli@latest
playwright-cli install --skills
- name: Install bbin and clj-nrepl-eval
run: |
# Install babashka
curl -sLO https://raw.githubusercontent.com/babashka/babashka/master/install
chmod +x install
sudo ./install
rm install
# Install bbin
mkdir -p ~/.bbin/bin
curl -sL https://raw.githubusercontent.com/babashka/bbin/main/bbin > ~/.bbin/bin/bbin
chmod +x ~/.bbin/bin/bbin
export PATH="$HOME/.bbin/bin:$PATH"
export BBIN_HOME="$HOME/.bbin"
# Install clj-nrepl-eval via bbin
bbin install https://github.com/bhauman/clojure-mcp-light.git \
--tag v0.2.1 \
--as clj-nrepl-eval \
--main-opts '["-m" "clojure-mcp-light.nrepl-eval"]'
# Add to PATH for the rest of the workflow
echo "$HOME/.bbin/bin" >> $GITHUB_PATH
# Verify
clj-nrepl-eval --help | head -3
- name: Register MCP servers
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY_REPRO_BOT }}
run: |
UV_PATH="$(which uv)"
jq -n \
--arg uv "$UV_PATH" \
--arg key "$LINEAR_API_KEY" \
'{
mcpServers: {
"metabase-repro": {
command: $uv,
args: ["run", "--directory", "mcp", "--with", "fastmcp", "python", "-m", "metabase_repro"]
},
"linear-server": {
url: "https://mcp.linear.app/mcp",
type: "http",
headers: {
Authorization: ("Bearer " + $key)
}
}
}
}' > ~/.claude.json
- name: Generate GitHub App Token
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@v2
id: claude-app-token
with:
app-id: ${{ secrets.METABASE_BOT_APP_ID }}
private-key: ${{ secrets.METABASE_BOT_APP_PRIVATE_KEY }}
- name: Run repro-bot with Claude Code
id: claude
continue-on-error: true
uses: anthropics/claude-code-action@v1
env:
CLAUDE_CODE_DEBUG_LOGS_DIR: /tmp/claude-debug-logs
CLAUDE_WORKING_DIR: ${{ github.workspace }}/repro-bot
MB_PREMIUM_EMBEDDING_TOKEN: ${{ secrets.PR_ENV_MB_PREMIUM_EMBEDDING_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
GITHUB_ACTIONS_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
timeout-minutes: 30
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_REPRO_BOT }}
github_token: ${{ steps.claude-app-token.outputs.token }}
plugins: |
@anthropic/plugin-productivity-linear
prompt: |
Read and follow the instructions in .claude/commands/repro.md to investigate this GitHub issue:
https://github.com/${{ github.repository }}/issues/${{ steps.issue.outputs.number }}
@@ -86,15 +143,124 @@ jobs:
- The bare git repo has been pre-cloned at git/metabase. The master worktree still needs setup via setup_master_worktree().
- The GITHUB_ACTIONS_RUN_URL environment variable is set. If any phase fails catastrophically, follow the CI error handling in repro.md to post the error to Linear with the action run URL.
- Docker is available for appdb (Postgres/MySQL/MariaDB) if needed.
- Java 21 and Python/uv are pre-installed.
claude_args: |
--allowedTools "Bash(playwright-cli*),Bash(gh *),Bash(jq*),Bash(find*),Bash(ls*),Bash(grep*),Bash(cat*),Bash(base64*),Bash(git*),Bash(npx*),Bash(docker*),Bash(kill*),Bash(curl*),Write,Read,Glob,Grep,Edit,MultiEdit,WebFetch,mcp__metabase_repro__*,mcp__plugin_productivity_linear__*"
- Java and Python/uv are pre-installed.
claude_args: >-
--allowedTools
"Glob,
Grep,
Read,
ToolSearch,
Skill(*),
WebSearch,
WebFetch(domain:docs.metabase.com),
WebFetch(domain:metabase.com),
WebFetch(domain:www.metabase.com),
Edit(//./**),
Edit(repro/**),
Edit(/tmp/**),
Write(//./**),
Write(repro/**),
Write(/tmp/**),
Bash(base64 *),
Bash(cat *),
Bash(cd *),
Bash(clj-nrepl-eval *),
Bash(clojure *),
Bash(cp *),
Bash(diff *),
Bash(docker *),
Bash(echo *),
Bash(find *),
Bash(gh api repos/metabase/metabase/*),
Bash(gh issue list * --repo metabase/metabase *),
Bash(gh issue view * --repo metabase/metabase *),
Bash(gh release *),
Bash(gh search *),
Bash(git *),
Bash(grep *),
Bash(head *),
Bash(jar *),
Bash(jq *),
Bash(kill *),
Bash(ls *),
Bash(mkdir *),
Bash(mv *),
Bash(npx playwright-cli:*),
Bash(pgrep *),
Bash(playwright-cli:*),
Bash(printf *),
Bash(ps *),
Bash(psql *),
Bash(PGPASSWORD=*),
Bash(python -m mcp.metabase_repro *),
Bash(python3 *),
Bash(sed *),
Bash(sort *),
Bash(strings *),
Bash(tail *),
Bash(tee *),
Bash(touch *),
Bash(unzip *),
Bash(wc *),
Bash(which *),
Bash(xargs *),
mcp__metabase-repro__api_call,
mcp__metabase-repro__browser_snapshot,
mcp__metabase-repro__check_setup,
mcp__metabase-repro__check_setup_token,
mcp__metabase-repro__cleanup,
mcp__metabase-repro__create_env,
mcp__metabase-repro__get_session_token,
mcp__metabase-repro__get_status,
mcp__metabase-repro__jar_download,
mcp__metabase-repro__repl_eval,
mcp__metabase-repro__reset_metabase,
mcp__metabase-repro__run_test,
mcp__metabase-repro__save_test_patch,
mcp__metabase-repro__setup_master_worktree,
mcp__metabase-repro__setup_worktree,
mcp__metabase-repro__start_appdb,
mcp__metabase-repro__start_metabase,
mcp__metabase-repro__stop_appdb_tool,
mcp__metabase-repro__stop_metabase,
mcp__linear-server__create_attachment,
mcp__linear-server__get_issue,
mcp__linear-server__list_comments,
mcp__linear-server__list_issues,
mcp__linear-server__list_issue_labels,
mcp__linear-server__save_comment,
mcp__linear-server__save_issue"
--disallowedTools "Bash(git push*),Bash(rm -rf /*)"
- name: Upload SUGGESTIONS.md artifact
uses: actions/upload-artifact@v4
- name: Verify repro-bot completed successfully
run: |
if [ "${{ steps.claude.outcome }}" = "failure" ]; then
echo "::error::Claude Code crashed or exited with an error"
exit 1
fi
EXEC_FILE="${{ steps.claude.outputs.execution_file }}"
if [ -z "$EXEC_FILE" ] || [ ! -f "$EXEC_FILE" ]; then
echo "::error::No execution file found"
exit 1
fi
if ! grep -q "mcp__linear-server__" "$EXEC_FILE"; then
echo "::error::Claude did not post a comment on Linear"
exit 1
fi
echo "Repro-bot completed successfully and posted to Linear"
- name: Upload repro-bot artifacts
uses: actions/upload-artifact@v7
if: always()
with:
name: repro-suggestions
path: repro/*/SUGGESTIONS.md
name: repro-bot-${{ steps.issue.outputs.number }}
if-no-files-found: ignore
path: |
repro/*/SUGGESTIONS.md
repro/*/*.log
repro/*/*.patch
repro/*/playwright/**
${{ steps.claude.outputs.execution_file }}

View File

@@ -40,11 +40,19 @@ jobs:
const BRANCH = process.env.BRANCH_NAME;
if (ATTEMPT <= MAX_ATTEMPTS) {
github.rest.actions.reRunWorkflowFailedJobs({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
try {
await github.rest.actions.reRunWorkflowFailedJobs({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
} catch (error) {
if (error.status === 403) {
console.log(`Skipping re-run: ${error.message}`);
} else {
throw error;
}
}
} else if (!BRANCH.includes('backport-')) {
// notify slack of repeated failure
const { WebClient } = require('@slack/web-api');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -25,6 +25,18 @@ For detailed guidance on writing and reviewing code and documentation, see the s
**Important**: When working with frontend code, read [frontend/CLAUDE.md](frontend/CLAUDE.md) for project-specific guidelines on component preferences, styling, TypeScript migration, testing requirements, and available scripts.
## Running Backend Tests
Use `./bin/test-agent` to run Clojure tests. It produces clean, plain-text output with no progress bars or ANSI codes.
```bash
./bin/test-agent :only '[metabase.foo-test]' # run a namespace
./bin/test-agent :only '[metabase.foo-test/some-test]' # run a single test
./bin/test-agent :only '[metabase.foo-test metabase.bar-test]' # multiple namespaces
```
Do not use `clj -X:dev:test` directly — its progress-bar output is hard to parse.
## Tool Preferences
If `clojure-mcp` tools are available, prefer them over shell-based alternatives for Clojure development.

11
bin/e2e-backend Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${MB_JETTY_PORT:-4000}"
if curl -sf "http://localhost:$PORT/api/health" > /dev/null 2>&1; then
echo "⚠️ Backend already running on :$PORT — reusing it. If you need a different MB_EDITION, kill it first: lsof -ti:$PORT | xargs kill"
exit 0
fi
exec node e2e/runner/start-backend.js

42
bin/e2e-stress-test Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
RUNS="${E2E_STRESS_RUNS:-5}"
if [ $# -eq 0 ]; then
echo "Usage: bin/e2e-stress-test [cypress args...]"
echo " e.g. bin/e2e-stress-test --spec e2e/test/scenarios/organization/bookmarks-dashboard.cy.spec.js"
echo ""
echo "Set E2E_STRESS_RUNS=N to change iteration count (default: 5)"
exit 1
fi
rm -rf cypress/screenshots cypress/videos
for i in $(seq 1 "$RUNS"); do
echo ""
echo "=========================================="
echo " Run $i / $RUNS"
echo "=========================================="
echo ""
if ! CYPRESS_VIDEO=false CYPRESS_RETRIES=0 CYPRESS_GUI=false GENERATE_SNAPSHOTS=false bun test-cypress "$@"; then
echo ""
echo "=========================================="
echo " FAILED on run $i / $RUNS"
echo "=========================================="
if ls cypress/screenshots/**/*.png 1>/dev/null 2>&1; then
echo ""
echo "Screenshots:"
find cypress/screenshots -name "*.png" -print
fi
exit 1
fi
done
echo ""
echo "=========================================="
echo " All $RUNS runs passed"
echo "=========================================="

31
bin/test-agent Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Agent-friendly test runner for Metabase backend (Clojure) tests.
#
# Differences from a normal `clj -X:dev:test` run:
# - Uses HAWK_MODE=cli/ci so eftest.report.pretty is used instead of the
# progress-bar reporter. Failures print inline as they happen.
# - Strips ANSI escape codes from output so results are plain text.
# - Filters Maven download noise and reflection warnings.
#
# Usage:
# bin/test-agent :only '[metabase.foo-test]'
# bin/test-agent :only '[metabase.foo-test/some-test]'
# bin/test-agent # runs all tests (rarely what you want)
#
# Any extra arguments are forwarded directly to `clojure -X:dev:ee:ee-dev:test`.
# The :test alias uses port 0 (OS-assigned random port) so parallel runs never collide.
set -euo pipefail
export HAWK_MODE=cli/ci
# Filter noise and strip ANSI escape sequences
clean_output() {
grep -v '^Downloading: ' \
| grep -v '^Reflection warning,' \
| grep -v '^Excluding directory' \
| grep -v '^Looking for test namespaces in directory' \
| sed $'s/\033[\[(][0-9;]*[A-Za-z]//g; s/\033[^[]*//g'
}
exec clojure -X:dev:ee:ee-dev:test "$@" 2>&1 | clean_output

View File

@@ -102,7 +102,7 @@
"hast-util-from-html": "^2.0.1",
"hast-util-to-html": "^9.0.0",
"history": "3",
"html2canvas-pro": "1.5.0",
"html2canvas-pro": "^2.0.2",
"humanize-plus": "^1.8.1",
"icepick": "2.4.0",
"iframe-resizer": "~4.3.2",
@@ -137,12 +137,12 @@
"react-dnd-html5-backend": "4",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-grid-layout": "^1.4.4",
"react-grid-layout": "^1.5.3",
"react-innertext": "^1.1.5",
"react-is": "^18.2.0",
"react-markdown": "^9.0.0",
"react-redux": "^9.1.2",
"react-resizable": "^3.0.5",
"react-resizable": "^3.1.3",
"react-router": "3",
"react-router-redux": "^4.0.8",
"react-transition-group": "^4.4.5",
@@ -3270,7 +3270,7 @@
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"html2canvas-pro": ["html2canvas-pro@1.5.0", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-izxSphcINRwfEVV6eamsPVdhsxSYqX8n/hxzK+niVWdB+onM+aYRoVO+xgS9iMmZoUleZTWg1tJwryikib2hXg=="],
"html2canvas-pro": ["html2canvas-pro@2.0.2", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw=="],
"htmlparser2": ["htmlparser2@6.1.0", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.0.0", "domutils": "^2.5.2", "entities": "^2.0.0" } }, "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A=="],
@@ -4504,13 +4504,13 @@
"react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="],
"react-draggable": ["react-draggable@4.4.6", "", { "dependencies": { "clsx": "^1.1.1", "prop-types": "^15.8.1" } }, "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw=="],
"react-draggable": ["react-draggable@4.5.0", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw=="],
"react-dropzone": ["react-dropzone@14.2.3", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.6.0", "prop-types": "^15.8.1" } }, "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug=="],
"react-fast-compare": ["react-fast-compare@2.0.4", "", {}, "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="],
"react-grid-layout": ["react-grid-layout@1.5.0", "", { "dependencies": { "clsx": "^2.0.0", "fast-equals": "^4.0.3", "prop-types": "^15.8.1", "react-draggable": "^4.4.5", "react-resizable": "^3.0.5", "resize-observer-polyfill": "^1.5.1" } }, "sha512-WBKX7w/LsTfI99WskSu6nX2nbJAUD7GD6nIXcwYLyPpnslojtmql2oD3I2g5C3AK8hrxIarYT8awhuDIp7iQ5w=="],
"react-grid-layout": ["react-grid-layout@1.5.3", "", { "dependencies": { "clsx": "^2.1.1", "fast-equals": "^4.0.3", "prop-types": "^15.8.1", "react-draggable": "^4.4.6", "react-resizable": "^3.0.5", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g=="],
"react-innertext": ["react-innertext@1.1.5", "", {}, "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q=="],
@@ -4532,7 +4532,7 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable": ["react-resizable@3.0.5", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.0.3" } }, "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w=="],
"react-resizable": ["react-resizable@3.1.3", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.5.0" }, "peerDependencies": { "react": ">= 16.3", "react-dom": ">= 16.3" } }, "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw=="],
"react-router": ["react-router@3.2.6", "", { "dependencies": { "create-react-class": "^15.5.1", "history": "^3.0.0", "hoist-non-react-statics": "^3.3.2", "invariant": "^2.2.1", "loose-envify": "^1.2.0", "prop-types": "^15.7.2", "react-is": "^16.13.0", "warning": "^3.0.0" } }, "sha512-nlxtQE8B22hb/JxdaslI1tfZacxFU8x8BJryXOnR2RxB4vc01zuHYAHAIgmBkdk1kzXaA25hZxK6KAH/+CXArw=="],
@@ -6534,8 +6534,6 @@
"mdast-util-to-markdown/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"miller-rabin/bn.js": ["bn.js@4.11.9", "", {}, "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw=="],
"mini-css-extract-plugin/schema-utils": ["schema-utils@4.0.0", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.8.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.0.0" } }, "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg=="],

View File

@@ -47,6 +47,8 @@
com.github.seancorfield/honeysql {:mvn/version "2.7.1350"} ; Honey SQL 2. SQL generation from Clojure data maps
com.github.seancorfield/next.jdbc {:mvn/version "1.3.1070"} ; Talk to JDBC DBs
com.github.steffan-westcott/clj-otel-api {:mvn/version "0.2.8"} ; Telemetry library
com.github.steffan-westcott/clj-otel-exporter-otlp {:mvn/version "0.2.8"} ; OTLP span exporter (gRPC and HTTP)
com.github.steffan-westcott/clj-otel-sdk {:mvn/version "0.2.8"} ; OpenTelemetry SDK for programmatic configuration
com.github.vertical-blank/sql-formatter {:mvn/version "2.0.5"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter
com.google.guava/guava {:mvn/version "33.5.0-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697
com.h2database/h2 ^:antq/exclude ; https://metaboat.slack.com/archives/CKZEMT1MJ/p1727191010259979
@@ -330,9 +332,9 @@
"-Dmb.jetty.join=false"
"-Dmb.field.filter.operators.enabled=true"
"-Dmb.api.key=test-api-key"
;; Different port from normal `:dev` so you can run tests on a different server.
;; TODO -- figure out how to do a random port like in the old project.clj?
"-Dmb.jetty.port=3001"]}
;; Port 0 tells the OS to assign a random free port, allowing parallel test runs.
;; The actual port is read from the running Jetty server via server.instance/server-port.
"-Dmb.jetty.port=0"]}
;; run the dev server with
;; clojure -M:run

View File

@@ -35,7 +35,7 @@ Currently, Metabase can create transforms on the following databases:
You can't create transforms on databases that have [Database routing](../../permissions/database-routing.md) enabled, or on Metabase's Sample Database.
Transforms will create tables in your database, so the database user you use for your connection must have appropriate privileges. See [Database users, roles, and privileges](../../databases/users-roles-privileges.md).
Transforms will create tables in your database, so the database user you use for your connection must have appropriate privileges. See [Database users, roles, and privileges](../../databases/users-roles-privileges.md). We suggest using a [Writable connection](../../databases/writable-connection.md) option for your database.
## Types of transforms

View File

@@ -209,9 +209,9 @@ If Metabase also needs to create tables, you'll need additional AWS Glue permiss
}
```
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -161,9 +161,9 @@ FROM [my_dataset.my_table]
If you're having trouble with your BigQuery connection, you can check out this [troubleshooting guide](../../troubleshooting-guide/bigquery-drive.md) that covers BigQuery issues, [this one](../../troubleshooting-guide/db-connection.md) on data warehouse connections, or visit [Metabase's discussion forum](https://discourse.metabase.com/search?q=bigquery) to see if someone has encountered and resolved a similar issue.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -101,9 +101,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -101,9 +101,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -64,9 +64,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -101,9 +101,9 @@ JSON schema inference doesn't work with MariaDB, due to implementation differenc
If your password contains characters that aren't UTF-8, then you might need to add an additional variable to the connection string `passwordCharacterEncoding=<your_encoding_here>`. This ensures that MariaDB understands the special characters in the password during authentication.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Database routing

View File

@@ -130,9 +130,9 @@ Instead, Metabase gets a sample of the fields in a collection by scanning a samp
If you're not seeing all of the fields show up for a collection in Metabase, one workaround is to include all possible keys in the first document of the collection, and give those keys null values. That way, Metabase will be able to recognize the correct schema for the entire collection.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Database routing

View File

@@ -187,9 +187,9 @@ mysql:
If your password contains characters that aren't UTF-8, then you might need to add an additional variable to the connection string `passwordCharacterEncoding=<your_encoding_here>`. This ensures that MySQL understands the special characters in the password during authentication.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -131,9 +131,9 @@ For example, if you're running Metabase from a directory called `/app/`, you sho
The process for adding plugins when running via Docker is similar, but you'll need to mount the `plugins` directory. Refer to instructions [here](../../installation-and-operation/running-metabase-on-docker.md#adding-external-dependencies-or-plugins) for more details.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Danger zone

View File

@@ -186,9 +186,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -94,9 +94,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Database routing

View File

@@ -98,9 +98,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -133,9 +133,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -77,9 +77,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Danger zone

View File

@@ -97,9 +97,9 @@ A fingerprinting query examines the first 10,000 rows from each column and uses
To connect to Azure SQL, you'll need to set the port to 1433.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Database routing

View File

@@ -61,9 +61,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Database routing

View File

@@ -91,9 +91,9 @@ Turn this option **ON** to scan a sample of values every time Metabase runs a [s
A fingerprinting query examines the first 10,000 rows from each column and uses that data to guesstimate how many unique values each column has, what the minimum and maximum values are for numeric and timestamp columns, and so on. If you leave this option **OFF**, Metabase will only fingerprint your columns once during setup.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -41,9 +41,9 @@ For example, if you're running Metabase from a directory called `/app/`, you sho
The process for adding plugins when running via Docker is similar, but you'll need to mount the `plugins` directory. Refer to instructions [here](../../installation-and-operation/running-metabase-on-docker.md#adding-external-dependencies-or-plugins) for more details.
## Writeable connection
## Writable connection
Set up an additional connection used for write operations. See [Writeable connections](../writeable-connection.md).
Set up an additional connection used for write operations. See [Writable connections](../writable-connection.md).
## Model features

View File

@@ -21,7 +21,7 @@ Bundling your privileges into roles based on use cases makes it easier to manage
- Use the same `analytics` role for other BI tools in your [data stack](https://www.metabase.com/learn/grow-your-data-skills/data-fundamentals/data-landscape) that need read-only access to the analytics tables in your database.
- Revoke the write access for `metabase_model_persistence` without affecting the write access for `metabase_actions`.
For operations that require write access, we recommend using separate [writeable connections](./writeable-connection.md).
For operations that require write access, we recommend using separate [writable connections](./writable-connection.md).
## Minimum database privileges
@@ -102,7 +102,7 @@ if you're using actions or editable tables, then in addition to the [minimum dat
- Create a new role called `metabase_writer`.
- Give the role `INSERT`, `UPDATE`, and `DELETE` privileges to the relevant tables.
- Give the `metabase_writer` role to the user used for your database connection. We recommend using a [writeable connection](./writeable-connection.md) and only granting this role to the user used for the writeable connection.
- Give the `metabase_writer` role to the user used for your database connection. We recommend using a [writable connection](./writable-connection.md) and only granting this role to the user used for the writable connection.
```sql
-- Create a role to bundle database privileges for Metabase writing to your database.
@@ -124,7 +124,7 @@ In addition to the [minimum database privileges](#minimum-database-privileges):
- Create a new role called `metabase_model_persistence`.
- Give the role `CREATE` access to the database.
- Give the role `INSERT`, `UPDATE`, and `DELETE` privileges to the schema used for model persistence.
- Give the `metabase_model_persistence` role to the user used for your database connection. We recommend using a [writeable connection](./writeable-connection.md) and only granting this role to the user used for the writeable connection.
- Give the `metabase_model_persistence` role to the user used for your database connection. We recommend using a [writable connection](./writable-connection.md) and only granting this role to the user used for the writable connection.
```sql
-- Create a role to bundle database privileges for Metabase model persistence.
@@ -144,20 +144,20 @@ GRANT metabase_model_persistence TO metabase;
## Privileges to enable transforms
[Transforms](../data-studio/transforms/transforms-overview.md) let Metabase write query results back to your database. We suggest that you create a dedicated schema for your transforms. Metabase's database user will need to be able to create and drop transform tables. We recommend using a [writeable connection](./writeable-connection.md) and only granting write access to the user used for the writeable connection.
[Transforms](../data-studio/transforms/transforms-overview.md) let Metabase write query results back to your database. We suggest that you create a dedicated schema for your transforms. Metabase's database user will need to be able to create and drop transform tables. We recommend using a [writable connection](./writable-connection.md) and only granting write access to the user used for the writable connection.
In addition to the [minimum database privileges](#minimum-database-privileges):
- Create a new role called `metabase_transforms`.
- Create a dedicated schema for transforms.
- Give the `metabase_transforms` role `CREATE TABLE` / `ALTER` / `DROP` privileges for the specified schema.
- Give the `metabase_transforms` role to the user used for your database connection. We recommend using a [writeable connection](./writeable-connection.md) and only granting this role to the user used for the writeable connection.
- Give the `metabase_transforms` role to the user used for your database connection. We recommend using a [writable connection](./writable-connection.md) and only granting this role to the user used for the writable connection.
In certain cases, Metabase will give you an option to create new schemas for transforms, in which case you'll also need `CREATE SCHEMA` privileges for your metabase users (or `CREATE DATABASE` for ClickHouse).
## Privileges to enable uploads
You can [upload CSVs](../databases/uploads.md) to supported databases. Metabase's database user should have write access (`INSERT`, `UPDATE`, `DELETE`) to the schema where you want to store the uploads. We recommend using a [writeable connection](./writeable-connection.md) and only granting write access to the user used for the writeable connection.
You can [upload CSVs](../databases/uploads.md) to supported databases. Metabase's database user should have write access (`INSERT`, `UPDATE`, `DELETE`) to the schema where you want to store the uploads. We recommend using a [writable connection](./writable-connection.md) and only granting write access to the user used for the writable connection.
You'll first need to create a schema to store uploads (or use an existing schema) and tell Metabase that you want to [use that schema to store uploads](./uploads.md#select-the-database-and-schema-that-you-want-to-store-the-data-in).
@@ -228,7 +228,7 @@ We recommend bundling privileges into roles based on use cases per customer. Tha
## Further reading
- [Writeable connections](./writeable-connection.md)
- [Writable connections](./writable-connection.md)
- [Permissions strategies](https://www.metabase.com/learn/metabase-basics/administration/permissions/strategy)
- [Permissions introduction](../permissions/introduction.md)
- [People overview](../people-and-groups/start.md)

View File

@@ -0,0 +1,62 @@
---
title: Writable connection
summary: Add an optional separate database connection for write operations like transforms or editable tables to keep the main Metabase connection read-only for analytics.
redirect_from:
- /docs/latest/databases/writeable-connection
---
## Writable connection
> Writable connection requires the **Advanced transforms (SQL + Python)** add-on
_Admin > Databases > Writable connection_
Writable connection is an optional connection that Metabase will use for functionality that involves writing into the database (for example, transforms or CSV uploads). This allows you to keep your main Metabase connection read-only, and only to switch to connection with write permissions for the operations that require write access.
![Writable connection in database settings](./images/writeable-connection.png)
## How writable connection works
Writable connection is an additional connection option added to an existing database connection.
When a writable connection is configured for a database, Metabase automatically switches between connections behind the scenes based on what people are doing with the database. The main connection is used for querying, and the writable connection is used for [features that require writes](#features-that-use-writable-connection).
Writable connection can use a different database user than the main connection, or even connect to a different database. For example, your main connection (used for querying) can go to a read replica, but the writable connection can be to a primary database.
## Add a writable connection
To add a writable connection to a database:
1. Go to **Admin > Databases**.
2. Select the database to add a writable connection to.
3. Under **Writable connection**, click **Add writable connection**.
4. Metabase will pre-fill some fields with information from your main connection. Input the credentials for the writable connection.
5. Click **Save**. Metabase will try to connect using the new connection.
## Remove a writable connection
To remove a writable connection from a database:
1. Go to **Admin > Databases**.
2. Select the database you want to remove the writable connection from.
3. Under **Writable connection**, click **Remove writable connection**.
Once writable connection is removed, Metabase will revert to using the main connection for [features that need write access](#features-that-use-writable-connection), so your main connection will need to have appropriate database permissions, see [Users, roles, and privileges](./users-roles-privileges.md)
## Features that use writable connection
When configured, writable connection will be used for:
- [Transforms](../data-studio/transforms/transforms-overview.md)
- [CSV uploads](../databases/uploads.md)
- [Editable tables](../data-modeling/editable-tables.md)
- [Actions](../actions/introduction.md)
- [Model persistence](../data-modeling/model-persistence.md)
Your writable connection will need to have the privileges required for these operations. See [Users, roles, and privileges](./users-roles-privileges.md).
## Limitations
- Writable connection can't be used with [database routing](../permissions/database-routing.md).
- Writable connection must use the same database engine as the primary connection - so for example, if the primary connection is to a PostgreSQL database, the writable connection must be to a PostgreSQL database as well.

View File

@@ -1,60 +0,0 @@
---
title: Writeable connections
summary: Add an optional separate database connection for write operations like transforms or editable tables to keep the main Metabase connection read-only for analytics.
---
## Writeable connections
> Writeable connection requires the **Advanced transforms (SQL + Python)** add-on
_Admin > Databases > Writeable connection_
Writeable connection is an optional connection that Metabase will use for functionality that involves writing into the database (for example, transforms or CSV uploads). This allows you to keep your main Metabase connection read-only, and only to switch to connection with write permissions for the operations that require write access.
![Writeable connection in database settings](./images/writeable-connection.png)
## How writeable connection works
Writeable connection is an additional connection option added to an existing database connection.
When a writeable connection is configured for a database, Metabase automatically switches between connections behind the scenes based on what people are doing with the database. The main connection is used for querying, and the writeable connection is used for [features that require writes](#features-that-use-writeable-connection).
Writeable connection can use a different database user than the main connection, or even connect to a different database. For example, your main connection (used for querying) can go to a read replica, but the writeable connection can be to a primary database.
## Add a writeable connection
To add a writeable connection to a database:
1. Go to **Admin > Databases**.
2. Select the database to add a writeable connection to.
3. Under **Writeable connection**, click **Add writeable connection**.
4. Metabase will pre-fill some fields with information from your main connection. Input the credentials for the writeable connection.
5. Click **Save**. Metabase will try to connect using the new connection.
## Remove a writeable connection
To remove a writeable connection from a database:
1. Go to **Admin > Databases**.
2. Select the database you want to remove the writeable connection from.
3. Under **Writeable connection**, click **Remove writeable connection**.
Once writeable connection is removed, Metabase will revert to using the main connection for [features that need write access](#features-that-use-writeable-connection), so your main connection will need to have appropriate database permissions, see [Users, roles, and privileges](./users-roles-privileges.md)
## Features that use writeable connection
When configured, writeable connection will be used for:
- [Transforms](../data-studio/transforms/transforms-overview.md)
- [CSV uploads](../databases/uploads.md)
- [Editable tables](../data-modeling/editable-tables.md)
- [Actions](../actions/introduction.md)
- [Model persistence](../data-modeling/model-persistence.md)
Your writeable connection will need to have the privileges required for these operations. See [Users, roles, and privileges](./users-roles-privileges.md).
## Limitations
- Writeable connection can't be used with [database routing](../permissions/database-routing.md).
- Writeable connection must use the same database engine as the primary connection - so for example, if the primary connection is to a PostgreSQL database, the writeable connection must be to a PostgreSQL database as well.

View File

@@ -40,7 +40,7 @@ Different database have different setups, so _what_ you can route between (datab
Database routing **can't be used** on databases with:
- [Writeable connections](../databases/writeable-connection.md)
- [Writable connections](../databases/writable-connection.md)
- [Editable tables](../data-modeling/editable-tables.md)
- [Actions](../actions/introduction.md)
- [CSV uploads](../databases/uploads.md)

View File

@@ -6,36 +6,29 @@ title: Creating a HAR file for troubleshooting
HAR (short for _HTTP Archive_) files record network requests generated by your browser. HAR files can be helpful when troubleshooting issues with Metabase performance.
> HAR files can contain sensitive information that you submit during the recording, like passwords and API keys. Before sharing the HAR file with anyone, review the file contents in a text editor.
> The instructions below include steps for creating **HAR files that contain sensitive information** that you submit during the recording. Sensitive information includes session cookies and authentication information. Only record this information when specifically instructed, and when it is needed for diagnosing issues with sessions or authentication, etc. Before sharing such a HAR file with anyone, review the file contents in a text editor.
To create the HAR file, follow the instructions for your web browser:
- [Chrome](#create-a-har-file-in-chrome)
- [Edge](#create-a-har-file-in-edge)
- [Chrome or Edge](#create-a-har-file-in-google-chrome-and-microsoft-edge)
- [Firefox](#create-a-har-file-in-firefox)
- [Safari](#create-a-har-file-in-safari)
## Create a HAR file in Chrome
## Create a HAR file in Google Chrome and Microsoft Edge
1. Open Chrome **Developer tools**.
1. Open Chrome/Edge **Developer tools**.
You can right-click anywhere on the page and select "Inspect".
2. In Developer tools, switch to the **Network** tab.
The network log recording should start automatically.
3. With the Network tab open and recording in progress, repeat the steps to reproduce the issue.
3. Click on the small **gear icon** in the top bar of the developer tools to open Settings. Scroll down to the **Network** section and enable **Allow to generate HAR files with sensitive data**
4. Once you're finished reproducing the issue, right-click anywhere in the table of network calls and select **Save all as HAR with content**.
![Developer tools Networks settings with the option highlighted to Allow to generate HAR files with sensitive data](images/chrome-har-settings.png)
## Create a HAR file in Edge
4. With the Network tab open and recording in progress, repeat the steps to reproduce the issue.
1. Open Edge **Developer tools**. You can right-click anywhere on the page and select "Inspect".
2. In Developer tools, switch to the **Network** tab (look for the Wi-Fi icon). The network log recording should start automatically.
3. With the Network tab open and recording in progress, repeat the steps to reproduce the issue.
4. Once you're finished reproducing the issue, right-click anywhere in the table and select **Save all as HAR with content**
5. Once you're finished reproducing the issue, click the download icon on the far right of the toolbar just below the tab bar. Choose **Export HAR (with sensitive data)…** and save the file.
## Create a HAR file in Firefox
@@ -51,7 +44,7 @@ To create the HAR file, follow the instructions for your web browser:
1. If you haven't yet, enable the **Develop** menu by going to **Safari > Settings > Advanced**, and select **Show features for web developers**.
2. Open the Safari developer tools by going to **Develop > Show Web Inspector**.
2. Open the Safari developer tools by going to **Develop > Show Web Inspector** or by right-clicking anywhere on the page and selecting **Inspect Element** from the context menu.
3. In Developer tools, switch to the **Network** tab. The network log recording should start automatically.

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env node
const backend = require("./cypress-runner-backend");
async function main() {
const jarPath = process.env.JAR_PATH;
if (jarPath) {
await backend.runFromJar(jarPath);
} else {
await backend.runFromSource();
}
console.log(`Backend ready on :${process.env.MB_JETTY_PORT}`);
const cleanup = () => {
backend.stop();
process.exit();
};
process.on("SIGTERM", cleanup);
process.on("SIGINT", cleanup);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -181,7 +181,7 @@ const defaultConfig = {
viewportHeight: 800,
viewportWidth: 1280,
// enable video recording in run mode
video: true,
video: process.env["CYPRESS_VIDEO"] !== "false",
videoCompression: true,
};
@@ -213,7 +213,10 @@ const mainConfig = {
},
},
retries: {
runMode: 2,
runMode:
process.env["CYPRESS_RETRIES"] != null
? parseInt(process.env["CYPRESS_RETRIES"], 10)
: 2,
openMode: 0,
},
};

View File

@@ -7,6 +7,8 @@ const embeddingSdkComponentTestConfig = {
defaultCommandTimeout: 10000,
requestTimeout: 10000,
video: false,
experimentalMemoryManagement: true,
numTestsKeptInMemory: 1,
specPattern: "e2e/test-component/scenarios/embedding-sdk/**/*.cy.spec.tsx",
indexHtmlFile: "e2e/support/component-index.html",
supportFile: "e2e/support/component-cypress.js",

View File

@@ -111,6 +111,8 @@ export const DataStudio = {
dependenciesTab: () =>
DataStudio.Metrics.header().findByText("Dependencies"),
cachingTab: () => DataStudio.Metrics.header().findByText("Caching"),
overviewPageDescriptionSidebar: () =>
metricOverviewPage().findByTestId("metric-description-sidebar"),
},
Tables: {
overviewPage: () => cy.findByTestId("table-overview-page"),
@@ -150,6 +152,8 @@ export const DataStudio = {
.click(),
Overview: {
descriptionSidebar: () => cy.findByTestId("table-description-sidebar"),
descriptionText: () =>
cy
.findByTestId("table-description-section")

File diff suppressed because it is too large Load Diff

View File

@@ -242,6 +242,70 @@ describe(
});
});
it("should keep the resize handle connected to the cursor while resizing (metabase#70451)", () => {
H.createDashboardWithQuestions({
dashboardDetails: {},
questions: [
{
display: "table",
query: {
"source-table": ORDERS_ID,
limit: 10,
},
},
],
}).then(({ dashboard }) => {
H.visitDashboard(dashboard.id);
});
H.editDashboard();
let targetX, targetY;
H.getDashboardCard(0)
.find(".react-resizable-handle")
.then(($handle) => {
const rect = $handle[0].getBoundingClientRect();
const startX = Math.round(rect.left + rect.width / 2);
const startY = Math.round(rect.top + rect.height / 2);
targetX = startX + 200;
targetY = startY + 150;
// Not using the wrapper here because we want to avoid waits and we need the starting position for later
cy.wrap($handle)
.trigger("mousedown", {
button: 0,
clientX: startX,
clientY: startY,
force: true,
})
.trigger("mousemove", {
clientX: targetX,
clientY: targetY,
force: true,
});
});
// After dragging, the resize handle should still be near the cursor position.
H.getDashboardCard(0)
.find(".react-resizable-handle")
.then(($handle) => {
const rect = $handle[0].getBoundingClientRect();
const handleCenterX = Math.round(rect.left + rect.width / 2);
const handleCenterY = Math.round(rect.top + rect.height / 2);
const maxDrift = 50;
expect(
Math.abs(handleCenterX - targetX),
`Horizontal drift: handle at ${handleCenterX}, cursor at ${targetX}`,
).to.be.lessThan(maxDrift);
expect(
Math.abs(handleCenterY - targetY),
`Vertical drift: handle at ${handleCenterY}, cursor at ${targetY}`,
).to.be.lessThan(maxDrift);
});
});
it("should not allow cards to be resized smaller than min height", () => {
const cardIds = [];
TEST_QUESTIONS.forEach((question) => {

View File

@@ -58,12 +58,12 @@ describe("scenarios > dashboard cards > sections", () => {
selectQuestion("Orders, Count, Grouped by Created At (year)");
overwriteDashCardTitle(
1,
2,
"Orders, Count, Grouped by Created At (year)",
"Line chart",
);
// TODO: if the mapping is done before the title is changed, the mapping is lost
mapDashCardToFilter(H.getDashboardCard(1), "Category");
mapDashCardToFilter(H.getDashboardCard(2), "Category");
H.goToTab("Tab 1");
H.saveDashboard();
@@ -101,7 +101,7 @@ describe("scenarios > dashboard cards > sections", () => {
// Ensure parameter mapping is persisted
H.editDashboard();
filterPanel().findByText("Category").click();
H.getDashboardCard(1).findByText("Product.Category").should("exist");
H.getDashboardCard(2).findByText("Product.Category").should("exist");
});
});

View File

@@ -70,11 +70,18 @@ describe("scenarios > data studio > library > metrics", () => {
cy.findByText("Sum of all order totals across the store").should(
"be.visible",
);
});
H.DataStudio.Metrics.overviewPageDescriptionSidebar().within(() => {
cy.findByText("Last edited by Bobby Tables").should("be.visible");
cy.findByText("Sample Database").should("be.visible");
cy.findByText("PUBLIC").should("be.visible");
cy.findByText("Orders").should("be.visible");
cy.findByText("Creator and last editor").should("be.visible");
cy.findAllByText(/by Bobby Tables/).should("be.visible");
cy.findByText("Source table").should("be.visible");
cy.findByText("Dependencies").should("be.visible");
cy.findByText("Dependents").should("be.visible");
});
cy.log("Ensure chart is visible");

View File

@@ -83,6 +83,33 @@ describe("scenarios > data studio > library > tables", () => {
.contains("Table description updated")
.should("be.visible");
});
it("should be able to view additional properties in sidebar", () => {
H.DataStudio.Tables.Overview.descriptionSidebar().within(() => {
cy.findByText("Entity type").should("be.visible");
cy.findByText("Last edited at").should("be.visible");
cy.findByText("Database").should("be.visible");
cy.findByText("Sample Database").should("be.visible");
cy.findByText("Source").should("be.visible");
cy.findByPlaceholderText("Select a data source").should(
"have.value",
"Ingested",
);
cy.findByText("Owner").should("be.visible");
cy.findByPlaceholderText("Pick someone, or type an email").should(
"have.value",
"No owner",
);
cy.findByText("Fields").should("be.visible");
cy.findByText("Dependents").should("be.visible");
});
});
});
describe("fields", () => {

View File

@@ -16,7 +16,6 @@ import type {
CardType,
CollectionId,
PythonTransformTableAliases,
TransformId,
TransformSourceCheckpointStrategy,
TransformTagId,
} from "metabase-types/api";
@@ -1509,108 +1508,6 @@ LIMIT
H.assertQueryBuilderRowCount(1);
});
it("should be possible to continue editing a transform after closing check dependencies modal (metabase#68272)", () => {
const transformTableName = "output_table";
const dependentCardName = "Question depending on transform";
cy.log("create MBQL transform with name and score columns");
H.getTableId({ name: SOURCE_TABLE, databaseId: WRITABLE_DB_ID }).then(
(sourceTableId) => {
H.getFieldId({ tableId: sourceTableId, name: "name" }).then(
(nameFieldId) => {
H.getFieldId({ tableId: sourceTableId, name: "score" }).then(
(scoreFieldId) => {
H.createTransform(
{
name: "MBQL transform with deps",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "query",
query: {
"source-table": sourceTableId,
fields: [
["field", nameFieldId, null],
["field", scoreFieldId, null],
],
},
},
},
target: {
type: "table",
database: WRITABLE_DB_ID,
name: transformTableName,
schema: TARGET_SCHEMA,
},
},
{ wrapId: true },
);
},
);
},
);
},
);
cy.get<TransformId>("@transformId").then((transformId) => {
cy.log("run the transform to create the output table");
H.runTransformAndWaitForSuccess(transformId);
H.resyncDatabase({
dbId: WRITABLE_DB_ID,
tableName: transformTableName,
});
cy.log("create a question that depends on the score column");
H.getTableId({
databaseId: WRITABLE_DB_ID,
name: transformTableName,
}).then((tableId) => {
H.createQuestion({
name: dependentCardName,
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
filter: [
">",
["field", "score", { "base-type": "type/Integer" }],
10,
],
},
});
});
H.visitTransform(transformId);
cy.log("remove score column (breaking change)");
H.DataStudio.Transforms.clickEditDefinition();
H.getNotebookStep("data").findByLabelText("Pick columns").click();
H.popover().findByLabelText("Score").click();
H.DataStudio.Transforms.saveChangesButton().click();
cy.wait("@checkTransformDependencies");
H.modal().within(() => {
cy.findByText(
"These changes will break some other things. Save anyway?",
).should("be.visible");
cy.findByText(dependentCardName).should("be.visible");
cy.log("cancel to continue editing");
cy.button("Cancel").click();
});
H.DataStudio.Transforms.editDefinitionButton().should("not.exist");
H.DataStudio.Transforms.saveChangesButton()
.should("be.visible")
.and("be.enabled");
H.getNotebookStep("data").findByLabelText("Pick columns").click();
H.popover().findByLabelText("Name").should("be.disabled");
H.popover().findByLabelText("Score").should("not.be.checked");
});
});
it("should be able to update a Python query", { tags: ["@python"] }, () => {
H.setPythonRunnerSettings();
cy.log("create a new transform");

View File

@@ -25,72 +25,6 @@ describe("scenarios > dependencies > dependency checks", () => {
});
describe("questions", () => {
it("should be able to confirm or cancel breaking changes to a MBQL question", () => {
createMbqlQuestionWithDependentMbqlQuestions();
cy.log("make breaking changes");
H.visitQuestion("@questionId");
H.openNotebook();
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
cy.log("cancel breaking changes");
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
cy.findByText("Base question").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateCard.all").should("have.length", 0);
cy.log("confirm breaking changes");
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().button("Save anyway").click();
cy.wait("@updateCard");
});
it("should be able to navigate to affected questions or their collection", () => {
createMbqlQuestionWithDependentMbqlQuestions();
cy.log("check that we can navigate to the broken question");
H.visitQuestion("@questionId");
H.openNotebook();
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().findByText("Question with fields").click();
confirmDiscardChanges();
H.queryBuilderHeader()
.findByDisplayValue("Question with fields")
.should("be.visible");
cy.log("check that we can navigate to the collection of that question");
cy.go("back");
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Our analytics").click();
});
confirmDiscardChanges();
H.collectionTable().should("be.visible");
cy.log("check that we can navigate to the dashboard of that question");
cy.go("back");
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Orders in a dashboard").click();
});
confirmDiscardChanges();
H.dashboardHeader().should("be.visible");
});
it("should not show a confirmation if there are no breaking changes when updating a MBQL question", () => {
createMbqlQuestionWithDependentMbqlQuestions();
H.visitQuestion("@questionId");
@@ -105,37 +39,6 @@ describe("scenarios > dependencies > dependency checks", () => {
});
describe("metrics", () => {
it("should be able to confirm or cancel breaking changes to a metric", () => {
createMetricWithDependentMbqlQuestionsAndTransforms();
cy.log("make breaking changes");
cy.get<number>("@metricId").then(H.visitMetric);
H.openQuestionActions("Edit metric definition");
H.getNotebookStep("summarize").findByText("Min of Score").click();
H.popover().within(() => {
cy.icon("chevronleft").click();
cy.findByText("Maximum of ...").click();
cy.findByText("Score").click();
});
cy.log("cancel breaking changes");
cy.findByTestId("edit-bar").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Question with 2 stages").should("be.visible");
cy.findByText("Transform with 2 stages").should("be.visible");
cy.findByText("Question with 1 stage").should("not.exist");
cy.findByText("Transform with 1 stage").should("not.exist");
cy.findByText("Base metric").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateCard.all").should("have.length", 0);
cy.log("confirm breaking changes");
cy.findByTestId("edit-bar").button("Save changes").click();
H.modal().button("Save anyway").click();
cy.wait("@updateCard");
});
it("should not show a warning when a change to a metric is backward-compatible with existing content", () => {
createMetricWithDependentMbqlQuestionsAndTransforms();
cy.get<number>("@metricId").then(H.visitMetric);
@@ -154,21 +57,6 @@ describe("scenarios > dependencies > dependency checks", () => {
H.NativeEditor.clear().type(queryString);
};
it("should ignore breaking changes to a SQL transform after it was run", () => {
createSqlTransformWithDependentMbqlQuestions();
cy.log("make breaking changes");
cy.get<number>("@transformId").then(H.visitTransform);
goToEditorAndType('SELECT name FROM "Schema A"."Animals"');
cy.log("confirmation is shown");
H.DataStudio.Transforms.saveChangesButton().click();
cy.contains(
"h2",
"These changes will break some other things. Save anyway?",
);
});
it("should not show a confirmation if there are no breaking changes when updating a SQL transform after it was run", () => {
createSqlTransformWithDependentMbqlQuestions();
cy.get<number>("@transformId").then(H.visitTransform);
@@ -177,35 +65,6 @@ describe("scenarios > dependencies > dependency checks", () => {
cy.wait("@updateTransform");
});
it("should be able to confirm or cancel breaking changes to a MBQL transform before it was run", () => {
createMbqlTransformWithDependentMbqlTransforms();
cy.log("make breaking changes");
cy.get<number>("@transformId").then(H.visitTransform);
H.DataStudio.Transforms.clickEditDefinition();
H.getNotebookStep("data").findByLabelText("Pick columns").click();
H.popover().findByLabelText("Score").click();
cy.log("cancel breaking changes");
H.DataStudio.Transforms.saveChangesButton().click();
H.modal().within(() => {
cy.findByText("Score transform").should("be.visible");
cy.findByText("Name transform").should("not.exist");
cy.findByText("Base transform").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateTransform.all").should("have.length", 0);
cy.log("confirm breaking changes");
H.DataStudio.Transforms.saveChangesButton().click();
H.modal().within(() => {
cy.findByText("Score transform").should("be.visible");
cy.findByText("Name transform").should("not.exist");
cy.button("Save anyway").click();
});
cy.wait("@updateTransform");
});
it("should not show a confirmation if there are no breaking changes when updating a MBQL transform before it was run", () => {
createMbqlTransformWithDependentMbqlTransforms();
cy.get<number>("@transformId").then(H.visitTransform);
@@ -480,7 +339,3 @@ function createMbqlTransformWithDependentMbqlTransforms() {
},
);
}
function confirmDiscardChanges() {
H.modal().should("have.length", 2).last().button("Discard changes").click();
}

View File

@@ -1,6 +1,5 @@
(ns metabase-enterprise.analytics.stats
(:require
[java-time.api :as t]
[metabase-enterprise.advanced-config.settings :as advanced-config.settings]
[metabase-enterprise.scim.core :as scim]
[metabase-enterprise.semantic-search.core :as semantic-search]
@@ -36,12 +35,3 @@
{:name :semantic-search
:available (premium-features/enable-semantic-search?)
:enabled (semantic-search/supported?)}])
(defenterprise ee-transform-metrics
"Returns transform usage metrics for the Snowplow stats ping."
:feature :none
[]
(let [one-day-ago (t/minus (t/offset-date-time) (t/days 1))]
{:transforms (t2/count :model/Transform)
:transform_runs_last_24h (t2/count :model/TransformRun
:start_time [:>= one-day-ago])}))

View File

@@ -0,0 +1,24 @@
(ns metabase-enterprise.dependencies.async
"Single-threaded executor for dependency event work.
The two expensive post-save handlers (`::check-card-dependents` and `::update-card-dependents-metadata`)
submit their work here so the HTTP response returns immediately. Tasks execute serially
to avoid concurrent writes to dependent cards."
(:import
(java.util.concurrent Callable Executors ExecutorService ThreadFactory)))
(set! *warn-on-reflection* true)
(defonce ^:private executor
(delay (Executors/newSingleThreadExecutor
(reify ThreadFactory
(newThread [_ r]
(doto (Thread. r)
(.setName "dependency-event-worker")
(.setDaemon true)))))))
(defn submit!
"Submit dependency work to the single-threaded executor. Tasks execute serially."
[f]
(let [task (bound-fn* f)]
(.submit ^ExecutorService @executor ^Callable task)))

View File

@@ -1,5 +1,6 @@
(ns metabase-enterprise.dependencies.events
(:require
[metabase-enterprise.dependencies.async :as async]
[metabase-enterprise.dependencies.calculation :as deps.calculation]
[metabase-enterprise.dependencies.findings :as deps.findings]
[metabase-enterprise.dependencies.models.dependency :as models.dependency]
@@ -286,12 +287,14 @@
(methodical/defmethod events/publish-event! ::check-card-dependents
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(lib-be/with-metadata-provider-cache
(let [has-stale-dependents? (t2/with-transaction [_conn]
(deps.findings/upsert-analysis! object)
(deps.findings/mark-dependents-stale! :card (:id object)))]
(when has-stale-dependents?
(task.entity-check/trigger-entity-check-job!))))))
(async/submit!
(fn []
(lib-be/with-metadata-provider-cache
(let [has-stale-dependents? (t2/with-transaction [_conn]
(deps.findings/upsert-analysis! object)
(deps.findings/mark-dependents-stale! :card (:id object)))]
(when has-stale-dependents?
(task.entity-check/trigger-entity-check-job!))))))))
(derive ::check-transform :metabase/event)
(derive :event/create-transform ::check-transform)

View File

@@ -2,6 +2,7 @@
(:require
[clojure.core.cache :as cache]
[medley.core :as m]
[metabase-enterprise.dependencies.async :as async]
[metabase-enterprise.dependencies.dependency-types :as deps.dependency-types]
[metabase-enterprise.dependencies.metadata-provider :as deps.metadata-provider]
[metabase-enterprise.dependencies.models.dependency :as models.dependency]
@@ -197,4 +198,6 @@
[_ {{:keys [id dataset_query]} :object :keys [previous-object]}]
(when (and (premium-features/has-feature? :dependencies)
(not (lib/any-native-stage? dataset_query)))
(update-dependent-mbql-cards-metadata! dataset_query :card id previous-object :metadata/card)))
(async/submit!
(fn []
(update-dependent-mbql-cards-metadata! dataset_query :card id previous-object :metadata/card)))))

View File

@@ -25,8 +25,9 @@
- 3: Initial version
- 4: Disable naive sql validation
- 5: Added source entity tracking in analysis_finding_error table
- 6: Removed validate prefix from error_type in analysis_finding_error"
6)
- 6: Removed validate prefix from error_type in analysis_finding_error
- 7: Only mark inactive (not missing) field refs in :fields as soft (GHY-3157)"
7)
(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

@@ -1,10 +1,15 @@
(ns metabase-enterprise.dependencies.native-validation
(:require
[clojure.string :as str]
[metabase.driver :as driver]
[metabase.driver.sql :as driver.sql]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.schema :as lib.schema]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.query-processor.compile :as qp.compile]
[metabase.sql-tools.core :as sql-tools]
[metabase.util :as u]
[metabase.util.malli :as mu]))
(mu/defn- compile-query :- ::lib.schema/native-only-query
@@ -21,15 +26,233 @@
compiled (qp.compile/compile-with-inline-parameters with-params)]
(lib/native-query with-params (:query compiled))))
(defn- has-card-template-tags?
"Returns true if the query has any card-type template tags."
[query]
(some #(= (:type %) :card) (lib/all-template-tags query)))
(def ^:private card-placeholder-prefix
"Prefix used to generate placeholder table names for card references.
Card ref {{#N}} becomes mb__validat_card__N in the SQL sent to SQLGlot."
"mb__validat_card__")
(defn- parse-card-placeholder
"If table-name is a card placeholder (e.g. \"mb__VALIDAT_card__42\" or \"MB__VALIDAT_CARD__42\"),
return the card ID. Returns nil otherwise. Case-insensitive to handle driver normalization."
[table-name]
(let [lower (u/lower-case-en (str table-name))]
(when (str/starts-with? lower card-placeholder-prefix)
(parse-long (subs lower (count card-placeholder-prefix))))))
(defn- compile-toplevel-query
"Compile a query replacing card references with placeholder table names.
Card refs like {{#1}} are replaced with mb__validat_card__1.
Returns the compiled native-only-query, or nil if the original SQL contains
the placeholder prefix (collision guard)."
[query]
(let [stage (first (:stages query))
sql (:native stage)
ttags (:template-tags stage)
card-tags (into {} (filter #(= (:type (val %)) :card)) ttags)]
(when-not (str/includes? sql card-placeholder-prefix)
(let [modified-sql
(reduce (fn [s [tag-name tag]]
(str/replace s
(str "{{" tag-name "}}")
(str card-placeholder-prefix (:card-id tag))))
sql
card-tags)
non-card-tags
(into {} (remove #(= (:type (val %)) :card)) ttags)
modified-query
(-> query
(assoc-in [:stages 0 :native] modified-sql)
(assoc-in [:stages 0 :template-tags] non-card-tags))]
(compile-query modified-query)))))
(defn- table-deps
"Returns the set of table dep maps (e.g. {:table \"products\" :schema \"public\"})
referenced directly in the compiled native query.
Excludes card placeholder tables (mb__validat_card__N)."
[driver compiled]
(into #{}
(filter (fn [dep]
(and (:table dep)
(not (parse-card-placeholder (:table dep))))))
(driver/native-query-deps driver compiled)))
(defn- extract-source-entity
"Extract source entity info from a :single-column col-spec's source-columns.
When card-placeholders? is true, recognizes card placeholder table names (mb__validat_card__N).
Returns:
- {:kind :table, :spec table-spec} for a single real table source
- {:kind :card, :card-id N} for a single card placeholder source (only when card-placeholders?)
- :multiple for ambiguous sources (multiple sources of any kind)
- nil otherwise (no recognizable sources, or not a :single-column spec)"
[card-placeholders? col-spec]
(let [card-id-fn (if card-placeholders?
(fn [source] (some-> source :table :table parse-card-placeholder))
(constantly nil))]
(when (= (:type col-spec) :single-column)
(let [first-sources (first (:source-columns col-spec))
all-col-sources (filter #(= (:type %) :all-columns) first-sources)]
(cond
(empty? all-col-sources) nil
(> (count all-col-sources) 1)
(let [cards (keep card-id-fn all-col-sources)
tables (remove card-id-fn all-col-sources)]
(cond
(and (= 1 (count cards)) (empty? tables))
{:kind :card, :card-id (first cards)}
(and (empty? cards) (= 1 (count tables)))
{:kind :table, :spec (:table (first tables))}
:else :multiple))
:else
(let [source (first all-col-sources)
card-id (card-id-fn source)]
(if card-id
{:kind :card, :card-id card-id}
{:kind :table, :spec (:table source)})))))))
(defn- resolve-table-id
"Map a SQL table spec {:table name, :schema schema} to a Metabase table ID.
Returns table ID or nil if not found."
[driver mp table-spec]
(:table (sql-tools/find-table-or-transform
driver
(lib.metadata/tables mp)
(lib.metadata/transforms mp)
table-spec)))
(defn- card-column-exists?
"Check if a column name exists in a card's result-metadata (normalized comparison)."
[driver mp card-id col-name]
(when-let [card (lib.metadata/card mp card-id)]
(let [normalized (driver.sql/normalize-name driver col-name)]
(some #(= (driver.sql/normalize-name driver (:name %)) normalized)
(:result-metadata card)))))
(defn- enrich-error
"Enrich a :missing-column error with source entity information from its col-spec.
When card-placeholders? is true, checks card placeholder columns against card metadata:
valid columns return nil (suppressed), invalid ones get source attribution.
Suppresses :missing-table-alias errors for card placeholder table names.
For other error types, returns the error unchanged."
[driver mp card-placeholders? col-spec error]
(cond
;; Suppress missing-table-alias for card placeholders (e.g. SELECT * FROM mb__validat_card__1)
(and card-placeholders?
(= (:type error) :missing-table-alias)
(parse-card-placeholder (:name error)))
nil
(not= (:type error) :missing-column)
error
:else
(let [source (extract-source-entity card-placeholders? col-spec)]
(cond
;; Table source
(and (map? source) (= (:kind source) :table))
(if-let [table-id (resolve-table-id driver mp (:spec source))]
(assoc error :source-entity-type :table :source-entity-id table-id)
error)
;; Card placeholder source — check column against card metadata
(and (map? source) (= (:kind source) :card))
(let [card-id (:card-id source)]
(if (card-column-exists? driver mp card-id (:name error))
nil ;; column is valid, suppress error
(assoc error :source-entity-type :card :source-entity-id card-id)))
;; Multiple/ambiguous sources
(= source :multiple)
(assoc error :source-entity-type :unknown)
;; nil — unknown structure, leave unenriched for fallback
:else error))))
(defn- validate-with-sources
"Validate a compiled native query, enriching errors with source entity information
derived from the col-spec's source-columns.
When card-placeholders? is true, card placeholder sources are recognized and valid
columns are filtered out (enrich-error returns nil)."
[driver compiled card-placeholders?]
(let [sql (lib/raw-native-query compiled)
mp (lib/->metadata-provider compiled)
{:keys [used-fields returned-fields errors]} (sql-tools/field-references driver sql)
check-fields (fn [fields]
(mapcat (fn [col-spec]
(->> (sql-tools/resolve-field driver mp col-spec)
(keep :error)
(keep (partial enrich-error driver mp card-placeholders? col-spec))))
fields))]
(into #{}
(map (partial driver.sql/normalize-error driver))
(concat errors
(check-fields used-fields)
(check-fields returned-fields)))))
(defn- fallback-enrich
"Second-pass enrichment for errors that validate-with-sources couldn't attribute
(e.g., subquery sources). Falls back to the table-count approach."
[driver compiled errors]
(if (every? :source-entity-type errors)
errors
(let [mp (lib/->metadata-provider compiled)
tables (table-deps driver compiled)
source (cond
(empty? tables)
nil
(= 1 (count tables))
(let [table-spec (first tables)]
(if-let [table-id (if (int? (:table table-spec))
(:table table-spec)
(resolve-table-id driver mp table-spec))]
{:source-entity-type :table
:source-entity-id table-id}
{:source-entity-type :unknown}))
:else
{:source-entity-type :unknown})]
(if source
(into #{} (map (fn [error]
(if (or (:source-entity-type error)
(not= (:type error) :missing-column))
error
(merge error source))))
errors)
errors))))
(mu/defn validate-native-query
"Compiles a (native) query and validates that the fields and tables it refers to really exist.
Returns either nil or a list of errors."
Returns a set of errors, each enriched with source entity information when possible."
[driver :- :keyword
query :- ::lib.schema/query]
(->> query
compile-query
(driver/validate-native-query-fields driver)))
(into #{}
;; Strip :unknown source attribution — consumers should not distinguish between
;; "we tried and couldn't determine the source" vs "no source info available".
(map #(cond-> % (= :unknown (:source-entity-type %)) (dissoc :source-entity-type :source-entity-id)))
(if (has-card-template-tags? query)
(if-let [compiled (compile-toplevel-query query)]
(let [errors (validate-with-sources driver compiled true)]
(if (empty? errors)
errors
(fallback-enrich driver compiled errors)))
;; Fallback: cards with placeholder collision
(driver/validate-native-query-fields driver (compile-query query)))
(let [compiled (compile-query query)
errors (validate-with-sources driver compiled false)]
(if (empty? errors)
errors
(fallback-enrich driver compiled errors))))))
(mu/defn native-result-metadata
"Compiles a (native) query and calculates its result metadata"

View File

@@ -12,6 +12,7 @@
[metabase.analytics.core :as analytics]
[metabase.premium-features.core :refer [defenterprise]]
[metabase.search.engine :as search.engine]
[metabase.tracing.core :as tracing]
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.realize :as t2.realize]))
@@ -38,53 +39,54 @@
when semantic search returns too few results and some results were filtered out (e.g. due to permission checks)."
:feature :semantic-search
[search-ctx]
(try
(let [{:keys [results raw-count]}
(semantic.pgvector-api/query (semantic.env/get-pgvector-datasource!)
(semantic.env/get-index-metadata)
search-ctx)
final-count (count results)
threshold (semantic.settings/semantic-search-min-results-threshold)]
(if (or (>= final-count threshold)
(and (zero? raw-count)
;; :search-string is nil when using search to populate the list of tables for a given database in
;; the native query editor. Semantic search doesn't support this, so fallback in this case.
(not (str/blank? (:search-string search-ctx)))))
results
;; Fallback: semantic search found results but some were filtered out (e.g. due to permission checks), so try to
;; supplement with appdb search.
(tracing/with-span :search "search.semantic.execute" {:search/query-length (count (:search-string search-ctx))}
(try
(let [{:keys [results raw-count]}
(semantic.pgvector-api/query (semantic.env/get-pgvector-datasource!)
(semantic.env/get-index-metadata)
search-ctx)
final-count (count results)
threshold (semantic.settings/semantic-search-min-results-threshold)]
(if (or (>= final-count threshold)
(and (zero? raw-count)
;; :search-string is nil when using search to populate the list of tables for a given database in
;; the native query editor. Semantic search doesn't support this, so fallback in this case.
(not (str/blank? (:search-string search-ctx)))))
results
;; Fallback: semantic search found results but some were filtered out (e.g. due to permission checks), so try to
;; supplement with appdb search.
(let [fallback (fallback-engine)]
(log/debugf "Semantic search returned %d final results (< %d) from %d raw results, supplementing with %s search"
final-count threshold raw-count fallback)
(analytics/inc! :metabase-search/semantic-fallback-triggered {:fallback-engine fallback})
(analytics/observe! :metabase-search/semantic-results-before-fallback final-count)
(when (some-> (:offset-int search-ctx) pos?)
(log/warn "Using an offset with semantic search will produce strange results, e.g. missing expected results, or duplicating them across pages"))
(let [total-limit (semantic.settings/semantic-search-results-limit)
fallback-results (try
(cond->> (search.engine/results (assoc search-ctx :search-engine fallback))
;; The in-place engine returns a reducible (but not seqable) result that needs to
;; be realized before we concat and dedup with the semantic engine results.
(= :search.engine/in-place fallback)
(into [] (comp (map t2.realize/realize)
(take total-limit))))
(catch Throwable t
(log/warn t "Semantic search fallback errored, ignoring")
[]))
fallback-results (take total-limit fallback-results)
_ (analytics/observe! :metabase-search/semantic-fallback-results-usage (count fallback-results))
combined-results (concat results fallback-results)
deduped-results (m/distinct-by (juxt :model :id) combined-results)]
(take total-limit deduped-results)))))
(catch Exception e
(log/error e "Error executing semantic search, falling back to appdb")
(let [fallback (fallback-engine)]
(log/debugf "Semantic search returned %d final results (< %d) from %d raw results, supplementing with %s search"
final-count threshold raw-count fallback)
(analytics/inc! :metabase-search/semantic-fallback-triggered {:fallback-engine fallback})
(analytics/observe! :metabase-search/semantic-results-before-fallback final-count)
(when (some-> (:offset-int search-ctx) pos?)
(log/warn "Using an offset with semantic search will produce strange results, e.g. missing expected results, or duplicating them across pages"))
(let [total-limit (semantic.settings/semantic-search-results-limit)
fallback-results (try
(cond->> (search.engine/results (assoc search-ctx :search-engine fallback))
;; The in-place engine returns a reducible (but not seqable) result that needs to
;; be realized before we concat and dedup with the semantic engine results.
(= :search.engine/in-place fallback)
(into [] (comp (map t2.realize/realize)
(take total-limit))))
(catch Throwable t
(log/warn t "Semantic search fallback errored, ignoring")
[]))
fallback-results (take total-limit fallback-results)
_ (analytics/observe! :metabase-search/semantic-fallback-results-usage (count fallback-results))
combined-results (concat results fallback-results)
deduped-results (m/distinct-by (juxt :model :id) combined-results)]
(take total-limit deduped-results)))))
(catch Exception e
(log/error e "Error executing semantic search, falling back to appdb")
(let [fallback (fallback-engine)]
(analytics/inc! :metabase-search/semantic-error-fallback {:fallback-engine fallback})
(if fallback
(search.engine/results (assoc search-ctx :search-engine fallback))
(throw (ex-info "Error executing semantic search" {:type :semantic-search-error} e)))))))
(analytics/inc! :metabase-search/semantic-error-fallback {:fallback-engine fallback})
(if fallback
(search.engine/results (assoc search-ctx :search-engine fallback))
(throw (ex-info "Error executing semantic search" {:type :semantic-search-error} e))))))))
(defenterprise update-index!
"Enterprise implementation of semantic index updating."

View File

@@ -6,6 +6,7 @@
[metabase-enterprise.semantic-search.models.token-tracking :as semantic.models.token-tracking]
[metabase-enterprise.semantic-search.settings :as semantic-settings]
[metabase.analytics.core :as analytics]
[metabase.tracing.core :as tracing]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log])
@@ -279,26 +280,30 @@
[embedding-model texts process-fn & {:as opts}]
(when (seq texts)
(let [{:keys [model-name provider vector-dimensions]} embedding-model]
(u/profile (str "Generating embeddings " {:model model-name
:dimensions vector-dimensions
:texts (calc-token-metrics texts)})
(if (= "openai" provider)
(let [max-tokens-per-batch (semantic-settings/openai-max-tokens-per-batch)
batches (create-batches max-tokens-per-batch count-tokens texts)
(tracing/with-span :search "search.semantic.embeddings-batch"
{:search.semantic/provider provider
:search.semantic/model-name model-name
:search.semantic/text-count (count texts)}
(u/profile (str "Generating embeddings " {:model model-name
:dimensions vector-dimensions
:texts (calc-token-metrics texts)})
(if (= "openai" provider)
(let [max-tokens-per-batch (semantic-settings/openai-max-tokens-per-batch)
batches (create-batches max-tokens-per-batch count-tokens texts)
process-batch
(fn [batch-idx batch-texts]
(let [embeddings (u/profile (format "Embedding batch %d/%d %s"
(inc batch-idx) (count batches) (str (calc-token-metrics batch-texts)))
(openai-get-embeddings-batch embedding-model batch-texts opts))
text-embedding-map (zipmap batch-texts embeddings)]
(process-fn text-embedding-map)))]
process-batch
(fn [batch-idx batch-texts]
(let [embeddings (u/profile (format "Embedding batch %d/%d %s"
(inc batch-idx) (count batches) (str (calc-token-metrics batch-texts)))
(openai-get-embeddings-batch embedding-model batch-texts opts))
text-embedding-map (zipmap batch-texts embeddings)]
(process-fn text-embedding-map)))]
(transduce (map-indexed process-batch) (partial merge-with +) batches))
(transduce (map-indexed process-batch) (partial merge-with +) batches))
(let [embeddings (get-embeddings-batch embedding-model texts opts)
text-embedding-map (zipmap texts embeddings)]
(process-fn text-embedding-map)))))))
(let [embeddings (get-embeddings-batch embedding-model texts opts)
text-embedding-map (zipmap texts embeddings)]
(process-fn text-embedding-map))))))))
(comment
;; Configuration:

View File

@@ -16,6 +16,7 @@
[metabase.models.interface :as mi]
[metabase.search.config :as search.config]
[metabase.search.core :as search]
[metabase.tracing.core :as tracing]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
@@ -389,20 +390,21 @@
model + model_id already exists, it will be replaced. Parallelizes batch insertion
using a shared thread pool with a configurable thread count (default: 2)."
([connectable index documents-reducible & {:keys [serial?] :or {serial? false}}]
(not-empty
(let [pool @index-update-executor
results (transduce
(comp (partition-all *batch-size*)
(map (if serial?
#(upsert-index-batch! connectable index % {:type :index})
#(upsert-index-pooled! pool connectable index % {:type :index}))))
conj
documents-reducible)]
(reduce (fn [update-counts result]
(let [value (if (future? result) @result result)]
(merge-with + update-counts (when value value))))
{}
results)))))
(tracing/with-span :search "search.semantic.index-upsert" {}
(not-empty
(let [pool @index-update-executor
results (transduce
(comp (partition-all *batch-size*)
(map (if serial?
#(upsert-index-batch! connectable index % {:type :index})
#(upsert-index-pooled! pool connectable index % {:type :index}))))
conj
documents-reducible)]
(reduce (fn [update-counts result]
(let [value (if (future? result) @result result)]
(merge-with + update-counts (when value value))))
{}
results))))))
(defn- drop-index-table-sql
[{:keys [table-name]}]
@@ -803,7 +805,10 @@
{:results [] :raw-count 0}
(let [timer (u/start-timer)
embedding (embedding/get-embedding embedding-model search-string {:type :query})
embedding (tracing/with-span :search "search.semantic.embedding"
{:search.semantic/provider (:provider embedding-model)
:search.semantic/model-name (:model-name embedding-model)}
(embedding/get-embedding embedding-model search-string {:type :query}))
embedding-time-ms (u/since-ms timer)
db-timer (u/start-timer)
@@ -813,14 +818,18 @@
xform (comp (map decode-metadata)
(map (partial legacy-input-with-score weights (keys scorers))))
reducible (reducible-search-query db query)
raw-results (into [] xform reducible)
raw-results (tracing/with-span :search "search.semantic.db-query"
{:search/query-length (count search-string)}
(into [] xform reducible))
db-query-time-ms (u/since-ms db-timer)
filter-timer (u/start-timer)
filtered-results (->> raw-results
filter-read-permitted
(apply-collection-id-filter search-context)
(mapv search/collapse-id))
filtered-results (tracing/with-span :search "search.semantic.permission-filter"
{:search.semantic/raw-count (count raw-results)}
(->> raw-results
filter-read-permitted
(apply-collection-id-filter search-context)
(mapv search/collapse-id)))
filter-time-ms (u/since-ms filter-timer)
appdb-scorers (scoring/appdb-scorers search-context)

View File

@@ -13,6 +13,8 @@
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
;; dummy change to trigger CI
(defn get-python-library-by-path
"Get Python library details by path for use by other APIs."
[path]

View File

@@ -1,12 +1,10 @@
(ns metabase-enterprise.analytics.stats-test
(:require
[clojure.test :refer :all]
[java-time.api :as t]
[metabase-enterprise.analytics.stats :as ee-stats]
[metabase-enterprise.audit-app.audit :as ee-audit]
[metabase.analytics.stats :as stats]
[metabase.app-db.core :as mdb]
[metabase.lib.core :as lib]
[metabase.test :as mt]
[toucan2.core :as t2]))
@@ -38,29 +36,3 @@
:public {}
:embedded {}}
(#'stats/dashboard-metrics)))))))
(deftest ee-transform-metrics-test
(mt/with-temp-empty-app-db [_conn :h2]
(mdb/setup-db! :create-sample-content? false)
(testing "with no transforms"
(is (=? {:transforms 0 :transform_runs_last_24h 0}
(ee-stats/ee-transform-metrics))))
(testing "with transforms"
(mt/with-temp [:model/Transform transform {:target {:database (mt/id)
:table "test_table"}
:name "Test SQL transform"
:source {:type "query"
:query (lib/native-query (mt/metadata-provider) "SELECT 1")}}
:model/TransformRun _ {:start_time (t/minus (t/offset-date-time) (t/hours 1))
:transform_id (:id transform)}]
(is (=? {:transforms 1 :transform_runs_last_24h 1}
(ee-stats/ee-transform-metrics))))
(testing "with old transform runs"
(mt/with-temp [:model/Transform transform {:target {:database (mt/id)
:table "test_table"}
:name "Test SQL transform"
:source {:type "query"
:query (lib/native-query (mt/metadata-provider) "SELECT 1")}}
:model/TransformRun _ {:start_time (t/minus (t/offset-date-time) (t/hours 25))
:transform_id (:id transform)}]
(is (zero? (:transform_runs_last_24h (ee-stats/ee-transform-metrics)))))))))

View File

@@ -819,50 +819,51 @@
(testing "GET /api/ee/dependencies/graph includes upstream nodes if ANY path to them is readable"
(mt/dataset test-data
(mt/with-premium-features #{:dependencies}
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection readable-collection {}
:model/Collection unreadable-collection {}
:model/User user {:email "test@test.com"}]
(mt/with-model-cleanup [:model/Card :model/Dependency]
(let [base-card (card/create-card! (assoc (basic-card) :collection_id (:id readable-collection)) user)
unreadable-middle (card/create-card! (assoc (wrap-card base-card)
:collection_id (:id unreadable-collection))
user)
readable-alternate (card/create-card! (assoc (wrap-card base-card)
:collection_id (:id readable-collection))
user)
end-card (card/create-card! (assoc (wrap-two-cards unreadable-middle readable-alternate)
:collection_id (:id readable-collection))
user)]
(perms/grant-collection-read-permissions! (perms/all-users-group) readable-collection)
(testing "Diamond pattern: complete upstream graph via readable path"
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph"
:id (:id end-card)
:type "card")
nodes (set (map (juxt :type :id) (:nodes response)))
expected-nodes #{["card" (:id end-card)] ["card" (:id readable-alternate)]
["card" (:id base-card)] ["table" (mt/id :orders)]}]
(is (= expected-nodes nodes)
"Should see end-card, readable-alternate, base-card, and :orders table")))
(testing "Edges show complete readable dependency chain"
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph"
:id (:id end-card)
:type "card")
edges (set (:edges response))
expected-edges #{{:from_entity_id (:id end-card)
:from_entity_type "card"
:to_entity_id (:id readable-alternate)
:to_entity_type "card"}
{:from_entity_id (:id readable-alternate)
:from_entity_type "card"
:to_entity_id (:id base-card)
:to_entity_type "card"}
{:from_entity_id (:id base-card)
:from_entity_type "card"
:to_entity_id (mt/id :orders)
:to_entity_type "table"}}]
(is (= expected-edges edges)
"Should have edges through readable path only")))))))))))
(mt/with-empty-h2-app-db!
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection readable-collection {}
:model/Collection unreadable-collection {}
:model/User user {:email "test@test.com"}]
(mt/with-model-cleanup [:model/Card :model/Dependency]
(let [base-card (card/create-card! (assoc (basic-card) :collection_id (:id readable-collection)) user)
unreadable-middle (card/create-card! (assoc (wrap-card base-card)
:collection_id (:id unreadable-collection))
user)
readable-alternate (card/create-card! (assoc (wrap-card base-card)
:collection_id (:id readable-collection))
user)
end-card (card/create-card! (assoc (wrap-two-cards unreadable-middle readable-alternate)
:collection_id (:id readable-collection))
user)]
(perms/grant-collection-read-permissions! (perms/all-users-group) readable-collection)
(testing "Diamond pattern: complete upstream graph via readable path"
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph"
:id (:id end-card)
:type "card")
nodes (set (map (juxt :type :id) (:nodes response)))
expected-nodes #{["card" (:id end-card)] ["card" (:id readable-alternate)]
["card" (:id base-card)] ["table" (mt/id :orders)]}]
(is (= expected-nodes nodes)
"Should see end-card, readable-alternate, base-card, and :orders table")))
(testing "Edges show complete readable dependency chain"
(let [response (mt/user-http-request :rasta :get 200 "ee/dependencies/graph"
:id (:id end-card)
:type "card")
edges (set (:edges response))
expected-edges #{{:from_entity_id (:id end-card)
:from_entity_type "card"
:to_entity_id (:id readable-alternate)
:to_entity_type "card"}
{:from_entity_id (:id readable-alternate)
:from_entity_type "card"
:to_entity_id (:id base-card)
:to_entity_type "card"}
{:from_entity_id (:id base-card)
:from_entity_type "card"
:to_entity_id (mt/id :orders)
:to_entity_type "table"}}]
(is (= expected-edges edges)
"Should have edges through readable path only"))))))))))))
(deftest graph-filtering-all-unreadable-test
(testing "GET /api/ee/dependencies/graph returns only root node when all upstream dependencies are unreadable"
@@ -1616,11 +1617,12 @@
(deftest ^:sequential unreferenced-sample-db-test
(testing "GET /api/ee/dependencies/unreferenced - should not return tables from the sample database"
(mt/with-premium-features #{:dependencies}
(mt/with-temp [:model/Database {db-id :id} {:is_sample true}
:model/Table _ {:db_id db-id :name "Sample DB table - unreftest"}]
(is (=? {:data []
:total 0}
(mt/user-http-request :crowberto :get 200 "ee/dependencies/graph/unreferenced?types=table&query=unreftest")))))))
(mt/with-empty-h2-app-db!
(mt/with-temp [:model/Database {db-id :id} {:is_sample true}
:model/Table _ {:db_id db-id :name "Sample DB table - unreftest"}]
(is (=? {:data []
:total 0}
(mt/user-http-request :crowberto :get 200 "ee/dependencies/graph/unreferenced?types=table&query=unreftest"))))))))
(deftest ^:sequential unreferenced-audit-db-test
(testing "GET /api/ee/dependencies/unreferenced - should not return tables from the audit database"

View File

@@ -197,7 +197,7 @@
:base-provider provider
:graph graph
:include-native? true)]
(is (= #{:card :transform} (set (keys errors))))
(is (= #{:card} (set (keys errors))))
;; That breaks (1) the SQL card which uses the snippets, (2) the transforms, (3) both the MBQL and (4) SQL
;; queries that consume the transform's table.
;; We only check :missing-column errors since SQLGlot and Macaw produce different

View File

@@ -2,6 +2,7 @@
(:require
[clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.dependencies.async :as async]
[metabase-enterprise.dependencies.calculation :as deps.calculation]
[metabase-enterprise.dependencies.events]
[metabase-enterprise.dependencies.findings :as deps.findings]
@@ -793,7 +794,8 @@
:from_entity_id child-card-id
:to_entity_type :card
:to_entity_id parent-card-id}]
(events/publish-event! :event/card-update {:object parent-card :previous-object parent-card :user-id api/*current-user-id*})
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update {:object parent-card :previous-object parent-card :user-id api/*current-user-id*}))
(assert-has-analyses
{:card {parent-card-id -1}})
(is (nil? (t2/select-one-fn :analysis_version :model/AnalysisFinding
@@ -828,7 +830,8 @@
other-card-id old-version}}))
(t2/with-transaction [_conn]
(binding [models.analysis-finding/*current-analysis-finding-version* new-version]
(events/publish-event! :event/card-update {:object parent-card :previous-object parent-card :user-id api/*current-user-id*}))
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update {:object parent-card :previous-object parent-card :user-id api/*current-user-id*})))
(testing "Parent should be re-analyzed synchronously"
(assert-has-analyses
{:card {parent-card-id new-version}}))
@@ -862,7 +865,8 @@
(deps.findings/upsert-analysis! child-card)
;; Update parent - this marks child as stale
(t2/with-transaction [_conn]
(events/publish-event! :event/card-update {:object parent-card :previous-object parent-card :user-id api/*current-user-id*})
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update {:object parent-card :previous-object parent-card :user-id api/*current-user-id*}))
;; Verify child is stale
(is (true? (t2/select-one-fn :stale :model/AnalysisFinding
:analyzed_entity_type :card
@@ -895,7 +899,8 @@
:analyzed_entity_id card-id))))
;; Try to update with mark-dependents-stale! throwing an exception
(binding [models.analysis-finding/*current-analysis-finding-version* new-version]
(with-redefs [deps.findings/mark-dependents-stale! (fn [_ _] (throw (ex-info "Simulated failure" {})))]
(with-redefs [async/submit! (fn [f] (f))
deps.findings/mark-dependents-stale! (fn [_ _] (throw (ex-info "Simulated failure" {})))]
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Simulated failure"
(events/publish-event! :event/card-update {:object card :previous-object card :user-id api/*current-user-id*})))))
;; Verify analysis was NOT updated (rolled back)
@@ -925,18 +930,19 @@
(deps.findings/upsert-analysis! child)
(t2/with-transaction [_conn]
(binding [models.analysis-finding/*current-analysis-finding-version* new-version]
(events/publish-event! :event/card-update {:object parent
:previous-object parent
:user-id api/*current-user-id*})
(testing "Native card update should trigger analysis"
(assert-has-analyses
{:card {parent-id new-version}}))
(testing "Its dependents should be marked stale"
(let [child-finding (t2/select-one :model/AnalysisFinding
:analyzed_entity_type :card
:analyzed_entity_id child-id)]
(is (= old-version (:analysis_version child-finding)))
(is (true? (:stale child-finding))))))))))))
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update {:object parent
:previous-object parent
:user-id api/*current-user-id*})
(testing "Native card update should trigger analysis"
(assert-has-analyses
{:card {parent-id new-version}}))
(testing "Its dependents should be marked stale"
(let [child-finding (t2/select-one :model/AnalysisFinding
:analyzed_entity_type :card
:analyzed_entity_id child-id)]
(is (= old-version (:analysis_version child-finding)))
(is (true? (:stale child-finding)))))))))))))
(deftest ^:sequential card-update-stops-on-transforms-test
(run-with-dependencies-setup!
@@ -974,22 +980,23 @@
:transform {transform-id old-version}}))
(binding [models.analysis-finding/*current-analysis-finding-version* new-version]
(t2/with-transaction [_conn]
(events/publish-event! :event/card-update {:object parent-card :previous-object parent-card :user-id api/*current-user-id*})
(testing "parent card should be re-analyzed synchronously"
(is (=? {:analysis_version new-version :stale false}
(t2/select-one :model/AnalysisFinding
:analyzed_entity_type :card
:analyzed_entity_id parent-card-id))))
(testing "transform (direct dependent) should be marked stale"
(is (=? {:analysis_version old-version :stale true}
(t2/select-one :model/AnalysisFinding
:analyzed_entity_type :transform
:analyzed_entity_id transform-id))))
(testing "child card (transitive dependent) should be marked stale"
(is (=? {:analysis_version old-version :stale true}
(t2/select-one :model/AnalysisFinding
:analyzed_entity_type :card
:analyzed_entity_id child-card-id))))))))))))
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update {:object parent-card :previous-object parent-card :user-id api/*current-user-id*})
(testing "parent card should be re-analyzed synchronously"
(is (=? {:analysis_version new-version :stale false}
(t2/select-one :model/AnalysisFinding
:analyzed_entity_type :card
:analyzed_entity_id parent-card-id))))
(testing "transform (direct dependent) should be marked stale"
(is (=? {:analysis_version old-version :stale true}
(t2/select-one :model/AnalysisFinding
:analyzed_entity_type :transform
:analyzed_entity_id transform-id))))
(testing "child card (transitive dependent) should be marked stale"
(is (=? {:analysis_version old-version :stale true}
(t2/select-one :model/AnalysisFinding
:analyzed_entity_type :card
:analyzed_entity_id child-card-id)))))))))))))
(deftest ^:sequential transform-update-works-with-no-analyses-test
(run-with-dependencies-setup!

View File

@@ -1,11 +1,15 @@
(ns metabase-enterprise.dependencies.findings-test
(:require
[clojure.test :refer :all]
[medley.core :as m]
[metabase-enterprise.dependencies.analysis :as deps.analysis]
[metabase-enterprise.dependencies.findings :as deps.findings]
[metabase-enterprise.dependencies.models.analysis-finding :as models.analysis-finding]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util :as lib.tu]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
@@ -72,8 +76,8 @@
:analyzed_entity_id card-id
:analyzed_entity_type :card))))))))
(deftest ^:sequential does-not-report-errors-in-removable-refs-test-1-stage-fields
(testing ":fields lists have soft refs that the QP will remove, so they're not considered analysis findings"
(deftest ^:sequential does-report-errors-for-missing-refs-in-fields-test
(testing "missing (not inactive) field refs in :fields are reported as findings (GHY-3157)"
(backfill-all-entity-analyses!)
(let [mp (mt/metadata-provider)
orders (lib.metadata/table mp (mt/id :orders))
@@ -86,7 +90,7 @@
(mt/with-premium-features #{:dependencies}
(mt/with-temp [:model/Card {card-id :id} {:dataset_query query}]
(is (= 1 (deps.findings/analyze-batch! :card 1)))
(is (= [models.analysis-finding/*current-analysis-finding-version* true]
(is (= [models.analysis-finding/*current-analysis-finding-version* false]
(t2/select-one-fn (juxt :analysis_version :result)
:model/AnalysisFinding
:analyzed_entity_id card-id
@@ -95,6 +99,28 @@
;; TODO: (bshepherdson, 2026-02-05) Add a test like does-not-report-errors-in-removable-refs-test-1-stage-fields for
;; join clause :fields as well. See QUE-3081 and QUE-3044.
(deftest ^:parallel reports-missing-field-in-downstream-card-fields-test
(testing "Q2 based on Q1 with :fields referencing a column Q1 no longer returns should be reported (GHY-3157)"
(let [mp meta/metadata-provider
q1 (lib/query mp (meta/table-metadata :orders))
q1-cols (lib/returned-columns q1)
removed-col (m/find-first #(= (:name %) "ID") q1-cols)
;; Card 101 = Q1 with ID removed from result-metadata (simulates Q1 dropping that field)
mp (lib.tu/metadata-provider-with-card-from-query
mp 101 q1
{:result-metadata (vec (remove #(= (:name %) "ID") q1-cols))})
;; Q2: query based on card 101, with :fields including the now-missing ID
q2-base (lib/query mp {:lib/type :metadata/card :id 101})
q2-cols (lib/returned-columns q2-base)
q2 (lib/with-fields q2-base (conj (vec q2-cols) (lib/ref removed-col)))
;; Card 102 = Q2 (provide result-metadata explicitly since q2 has a bad ref)
mp (lib.tu/metadata-provider-with-card-from-query
mp 102 q2
{:result-metadata (vec q2-cols)})
results (deps.analysis/check-entity mp :card 102)]
(is (seq results)
"Q2 should have findings for the missing ID column from Q1"))))
(defn- stale-map
"Returns a map of {entity-id stale?} for the given card IDs."
[card-ids]

View File

@@ -3,6 +3,7 @@
[clojure.java.jdbc :as jdbc]
[clojure.test :refer [deftest is testing]]
[java-time.api :as t]
[metabase-enterprise.dependencies.async :as async]
[metabase-enterprise.dependencies.dependency-types :as deps.dependency-types]
[metabase-enterprise.dependencies.events :as deps.events]
[metabase-enterprise.dependencies.findings :as deps.findings]
@@ -132,10 +133,11 @@
[:model/Card :id :result_metadata :card_schema]
:id [:in [parent-id child-id grandchild-id]])))
(t2/update! :model/Card parent-id {:dataset_query (lib/query mp orders)})
(events/publish-event! :event/card-update
{:object (assoc parent-card :dataset_query (lib/query mp orders))
:previous-object parent-card
:user-id api/*current-user-id*})
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update
{:object (assoc parent-card :dataset_query (lib/query mp orders))
:previous-object parent-card
:user-id api/*current-user-id*}))
(is (= #{9}
(t2/select-fn-set (comp count :result_metadata)
[:model/Card :id :result_metadata :card_schema]
@@ -156,10 +158,11 @@
:to_entity_type :card
:to_entity_id parent-id}]
(t2/update! :model/Card child-id {:result_metadata nil})
(events/publish-event! :event/card-update
{:object (assoc parent-card :dataset_query native-query)
:previous-object parent-card
:user-id api/*current-user-id*})
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update
{:object (assoc parent-card :dataset_query native-query)
:previous-object parent-card
:user-id api/*current-user-id*}))
(is (= nil
(t2/select-one-fn :result_metadata
[:model/Card :id :result_metadata :card_schema]
@@ -191,10 +194,11 @@
[0 :display_name]
"new-name")]
(t2/update! :model/Card parent-id {:result_metadata new-result-metadata})
(events/publish-event! :event/card-update
{:object (assoc parent-card :result_metadata new-result-metadata)
:previous-object parent-card
:user-id api/*current-user-id*})
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update
{:object (assoc parent-card :result_metadata new-result-metadata)
:previous-object parent-card
:user-id api/*current-user-id*}))
(is (= #{[child-id "new-name"] [grandchild-id "new-name"]}
(t2/select-fn-set (juxt :id #(get-in % [:result_metadata 0 :display_name]))
[:model/Card :id :result_metadata :card_schema]
@@ -233,10 +237,11 @@
(t2/update! :model/Card parent-id {:result_metadata new-parent-metadata})
(t2/update! :model/Card child-id {:result_metadata new-child-metadata})
(t2/update! :model/Card grandchild-id {:result_metadata new-grandchild-metadata})
(events/publish-event! :event/card-update
{:object (assoc parent-card :result_metadata new-parent-metadata)
:previous-object parent-card
:user-id api/*current-user-id*})
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update
{:object (assoc parent-card :result_metadata new-parent-metadata)
:previous-object parent-card
:user-id api/*current-user-id*}))
(is (= #{[child-id "child-name"] [grandchild-id "grandchild-name"]}
(t2/select-fn-set (juxt :id #(get-in % [:result_metadata 0 :display_name]))
[:model/Card :id :result_metadata :card_schema]
@@ -274,10 +279,11 @@
(t2/update! :model/Card parent-id {:result_metadata new-parent-metadata})
(t2/update! :model/Card child-id {:result_metadata new-child-metadata})
(t2/update! :model/Card grandchild-id {:result_metadata nil})
(events/publish-event! :event/card-update
{:object (assoc parent-card :result_metadata new-parent-metadata)
:previous-object parent-card
:user-id api/*current-user-id*})
(with-redefs [async/submit! (fn [f] (f))]
(events/publish-event! :event/card-update
{:object (assoc parent-card :result_metadata new-parent-metadata)
:previous-object parent-card
:user-id api/*current-user-id*}))
(is (= nil
(t2/select-one-fn #(get-in % [:result_metadata 0 :display_name])
[:model/Card :id :result_metadata :card_schema]

View File

@@ -180,41 +180,42 @@
(deftest dependency-analysis-version-test
(testing "dependency_analysis_version is updated when entities are created or updated"
(testing "cards"
(mt/with-model-cleanup [:model/Dependency]
(mt/with-temp [:model/Card card {:dependency_analysis_version 0}]
(is (zero? (t2/select-one-fn :dependency_analysis_version :model/Card (:id card))))
(mt/with-premium-features #{:dependencies}
(card/update-card! {:card-before-update card
:card-updates {:dataset_query (mt/mbql-query orders)}}))
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Card (:id card)))))))
(testing "transforms create"
(mt/with-premium-features #{:dependencies}
(mt/with-temp [:model/Transform transform]
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Transform (:id transform)))))))
(testing "transforms update"
(mt/with-premium-features #{}
(mt/with-temp [:model/Transform transform]
(is (zero? (t2/select-one-fn :dependency_analysis_version :model/Transform (:id transform))))
(mt/with-premium-features #{:dependencies}
(t2/update! :model/Transform (:id transform) {:source {:type "query" :query (mt/mbql-query products)}}))
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Transform (:id transform)))))))
(testing "snippets create"
(mt/with-premium-features #{:dependencies}
(mt/with-temp [:model/NativeQuerySnippet snippet]
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/NativeQuerySnippet (:id snippet)))))))
(testing "snippets update"
(mt/with-premium-features #{}
(mt/with-temp [:model/NativeQuerySnippet snippet]
(is (zero? (t2/select-one-fn :dependency_analysis_version :model/NativeQuerySnippet (:id snippet))))
(mt/with-premium-features #{:dependencies}
(t2/update! :model/NativeQuerySnippet (:id snippet) {:content "new content"}))
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/NativeQuerySnippet (:id snippet)))))))))
(mt/with-empty-h2-app-db!
(testing "cards"
(mt/with-model-cleanup [:model/Dependency]
(mt/with-temp [:model/Card card {:dependency_analysis_version 0}]
(is (zero? (t2/select-one-fn :dependency_analysis_version :model/Card (:id card))))
(mt/with-premium-features #{:dependencies}
(card/update-card! {:card-before-update card
:card-updates {:dataset_query (mt/mbql-query orders)}}))
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Card (:id card)))))))
(testing "transforms create"
(mt/with-premium-features #{:dependencies}
(mt/with-temp [:model/Transform transform]
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Transform (:id transform)))))))
(testing "transforms update"
(mt/with-premium-features #{}
(mt/with-temp [:model/Transform transform]
(is (zero? (t2/select-one-fn :dependency_analysis_version :model/Transform (:id transform))))
(mt/with-premium-features #{:dependencies}
(t2/update! :model/Transform (:id transform) {:source {:type "query" :query (mt/mbql-query products)}}))
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Transform (:id transform)))))))
(testing "snippets create"
(mt/with-premium-features #{:dependencies}
(mt/with-temp [:model/NativeQuerySnippet snippet]
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/NativeQuerySnippet (:id snippet)))))))
(testing "snippets update"
(mt/with-premium-features #{}
(mt/with-temp [:model/NativeQuerySnippet snippet]
(is (zero? (t2/select-one-fn :dependency_analysis_version :model/NativeQuerySnippet (:id snippet))))
(mt/with-premium-features #{:dependencies}
(t2/update! :model/NativeQuerySnippet (:id snippet) {:content "new content"}))
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/NativeQuerySnippet (:id snippet))))))))))
(deftest dependency-analysis-version-create-card-test
(testing "dependency_analysis_version is updated when a card is created"
@@ -227,37 +228,38 @@
(deftest filtered-graph-dependencies-test
(testing "filtered-graph-dependencies respects filter clause"
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
(mt/with-premium-features #{:dependencies}
(mt/with-model-cleanup [:model/Card :model/Dependency]
(let [{id1 :id :as card1} (card/create-card! (basic-orders) user)
{id2 :id :as card2} (card/create-card! (wrap-card card1) user)
{id3 :id :as _card3} (card/create-card! (wrap-card card2) user)]
(testing "without filter, returns all dependencies"
(let [graph (deps.graph/graph-dependencies)
deps (graph/transitive graph [[:card id3]])]
(is (= #{[:card id2] [:card id1] [:table (mt/id :orders)]}
(set deps)))))
(testing "with filter excluding card1, omits card1 and its dependencies"
(let [filter-fn (fn [entity-type-field entity-id-field]
[:and
[:= entity-type-field "card"]
[:in entity-id-field [id2 id3]]])
graph (deps.graph/filtered-graph-dependencies filter-fn)
deps (graph/transitive graph [[:card id3]])]
(is (= #{[:card id2]}
(set deps))
"Should only include card2, not card1 or table")))
(testing "with filter excluding card2, breaks the chain"
(let [filter-fn (fn [entity-type-field entity-id-field]
[:and
[:= entity-type-field "card"]
[:in entity-id-field [id1 id3]]])
graph (deps.graph/filtered-graph-dependencies filter-fn)
deps (graph/transitive graph [[:card id3]])]
(is (= #{}
(set deps))
"Should be empty, chain is broken at card2")))))))))
(mt/with-empty-h2-app-db!
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
(mt/with-premium-features #{:dependencies}
(mt/with-model-cleanup [:model/Card :model/Dependency]
(let [{id1 :id :as card1} (card/create-card! (basic-orders) user)
{id2 :id :as card2} (card/create-card! (wrap-card card1) user)
{id3 :id :as _card3} (card/create-card! (wrap-card card2) user)]
(testing "without filter, returns all dependencies"
(let [graph (deps.graph/graph-dependencies)
deps (graph/transitive graph [[:card id3]])]
(is (= #{[:card id2] [:card id1] [:table (mt/id :orders)]}
(set deps)))))
(testing "with filter excluding card1, omits card1 and its dependencies"
(let [filter-fn (fn [entity-type-field entity-id-field]
[:and
[:= entity-type-field "card"]
[:in entity-id-field [id2 id3]]])
graph (deps.graph/filtered-graph-dependencies filter-fn)
deps (graph/transitive graph [[:card id3]])]
(is (= #{[:card id2]}
(set deps))
"Should only include card2, not card1 or table")))
(testing "with filter excluding card2, breaks the chain"
(let [filter-fn (fn [entity-type-field entity-id-field]
[:and
[:= entity-type-field "card"]
[:in entity-id-field [id1 id3]]])
graph (deps.graph/filtered-graph-dependencies filter-fn)
deps (graph/transitive graph [[:card id3]])]
(is (= #{}
(set deps))
"Should be empty, chain is broken at card2"))))))))))
(deftest filtered-graph-dependents-test
(testing "filtered-graph-dependents respects filter clause"

View File

@@ -8,6 +8,7 @@
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util :as lib.tu]
[metabase.util :as u]))
(defn- fake-query
@@ -102,14 +103,18 @@
(is (= #{(lib/syntax-error)} result)
(str "Expected syntax-error or empty set, got: " result))))
(testing "bad table wildcard"
;; Normalize expected value using driver conventions (H2 uppercases, Postgres lowercases)
(is (= (normalize-error-names driver #{(lib/missing-table-alias-error "products")})
;; missing-table-alias errors don't get source attribution — the alias itself is unresolved
(is (= (normalize-error-names driver
#{(lib/missing-table-alias-error "products")})
(deps.native-validation/validate-native-query
driver
(fake-query mp "select products.* from orders")))))
(testing "bad col reference"
;; Normalize expected value using driver conventions (H2 uppercases, Postgres lowercases)
(is (= (normalize-error-names driver #{(lib/missing-column-error "bad")})
(is (= (normalize-error-names driver
#{(merge (lib/missing-column-error "bad")
{:source-entity-type :table
:source-entity-id (meta/id :products)})})
(deps.native-validation/validate-native-query
driver
(fake-query mp "select bad from products"))))))))
@@ -131,15 +136,23 @@
(testing "Valid query - selecting existing columns from subquery"
(validates? mp driver 10 empty?))
(testing "Invalid query - selecting non-existent column from subquery"
(validates? mp driver 11 #{(lib/missing-column-error "CATEGORY")})
(validates? mp driver 12 #{(lib/missing-column-error "CATEGORY")}))
(validates? mp driver 11 #{(merge (lib/missing-column-error "CATEGORY")
{:source-entity-type :table
:source-entity-id (meta/id :people)})})
(validates? mp driver 12 #{(merge (lib/missing-column-error "CATEGORY")
{:source-entity-type :table
:source-entity-id (meta/id :people)})}))
(testing "Nested subqueries"
(validates? mp driver 13 empty?)
(validates? mp driver 14 #{(lib/missing-column-error "CATEGORY")}))
(validates? mp driver 14 #{(merge (lib/missing-column-error "CATEGORY")
{:source-entity-type :table
:source-entity-id (meta/id :people)})}))
(testing "SELECT * from subquery expands to subquery columns"
(validates? mp driver 15 empty?)
(validates? mp driver 16 empty?)
(validates? mp driver 17 #{(lib/missing-column-error "EMAIL")})))))
(validates? mp driver 17 #{(merge (lib/missing-column-error "EMAIL")
{:source-entity-type :table
:source-entity-id (meta/id :people)})})))))
(deftest ^:parallel validate-card-reference-after-expansion-test
(testing "Validation of queries after card references have been expanded"
@@ -149,17 +162,55 @@
(validates? mp driver 18 empty?))
(testing "Card reference expanded to subquery - invalid column"
(validates? mp driver 19
#{(lib/missing-column-error "DESCRIPTION")}))
#{(merge (lib/missing-column-error "DESCRIPTION")
{:source-entity-type :card
:source-entity-id 1})}))
(testing "Card reference with alias - valid column"
(validates? mp driver 20 empty?))
(testing "Card reference with alias - invalid column"
(validates? mp driver 21
#{(lib/missing-column-error "PASSWORD")}))
#{(merge (lib/missing-column-error "PASSWORD")
{:source-entity-type :card
:source-entity-id 1})}))
(testing "Wildcard selection from card reference"
(validates? mp driver 22 empty?))
(testing "Invalid column from aliased card"
(validates? mp driver 23
#{(lib/missing-column-error "LATITUDE")})))))
#{(merge (lib/missing-column-error "LATITUDE")
{:source-entity-type :card
:source-entity-id 1})})))))
(deftest ^:parallel validate-card-reference-multi-card-test
(testing "Source attribution with multiple card references"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "Multi-card query, unqualified - source is unknown"
(validates? mp driver 24
#{(lib/missing-column-error "BAD")}))
(testing "Multi-card query, qualified - source attributed to specific card"
(validates? mp driver 25
#{(merge (lib/missing-column-error "BAD")
{:source-entity-type :card
:source-entity-id 1})})))))
(deftest ^:parallel validate-card-reference-mixed-table-card-test
(testing "Source attribution with mixed table and card references"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "Mixed table+card, unqualified - source is unknown"
(validates? mp driver 26
#{(lib/missing-column-error "BAD")}))
(testing "Mixed table+card, qualified to table - source attributed to table"
(validates? mp driver 27
#{(merge (lib/missing-column-error "BAD")
{:source-entity-type :table
:source-entity-id (meta/id :products)})}))
(testing "Mixed table+card, missing column + unknown alias"
(validates? mp driver 28
#{(merge (lib/missing-column-error "BAD")
{:source-entity-type :table
:source-entity-id (meta/id :products)})
(lib/missing-table-alias-error "xix")})))))
(defn- check-result-metadata [driver mp query expected]
(is (=? (normalize-result-metadata expected)
@@ -264,3 +315,204 @@
driver mp
"this is not a query"
[])))))
(deftest ^:parallel validate-native-query-source-attribution-test
(testing "errors include source entity info for table-only queries"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "single table query - error attributed to that table"
(is (= (normalize-error-names driver
#{{:type :missing-column
:name "bad"
:source-entity-type :table
:source-entity-id (meta/id :products)}})
(deps.native-validation/validate-native-query
driver
(fake-query mp "select bad from products")))))
(testing "multi-table query, unqualified - source is unknown"
(is (= (normalize-error-names driver
#{{:type :missing-column
:name "bad"}})
(deps.native-validation/validate-native-query
driver
(fake-query mp "select bad from products join orders on products.id = orders.product_id")))))
(testing "multi-table query, qualified - source attributed to specific table"
(is (= (normalize-error-names driver
#{{:type :missing-column
:name "bad"
:source-entity-type :table
:source-entity-id (meta/id :products)}})
(deps.native-validation/validate-native-query
driver
(fake-query mp "select products.bad from products join orders on products.id = orders.product_id")))))
(testing "multi-table query, qualified with alias - source attributed to specific table"
(is (= (normalize-error-names driver
#{{:type :missing-column
:name "bad"
:source-entity-type :table
:source-entity-id (meta/id :products)}})
(deps.native-validation/validate-native-query
driver
(fake-query mp "select p.bad from products p join orders o on p.id = o.product_id")))))
(testing "no errors - returns empty set"
(is (= #{}
(deps.native-validation/validate-native-query
driver
(fake-query mp "select * from products"))))))))
(deftest ^:parallel fallback-single-table-cte-test
(testing "CTE with single table - fallback attributes missing column to that table"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(is (= (normalize-error-names driver
#{{:type :missing-column
:name "bad"
:source-entity-type :table
:source-entity-id (meta/id :products)}})
(deps.native-validation/validate-native-query
driver
(fake-query mp "WITH cte AS (SELECT id, title FROM products) SELECT bad FROM cte")))))))
(deftest ^:parallel fallback-multi-table-cte-test
(testing "CTE with multiple tables - fallback produces unknown source"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(is (= (normalize-error-names driver
#{{:type :missing-column
:name "bad"}})
(deps.native-validation/validate-native-query
driver
(fake-query mp "WITH cte AS (SELECT p.id, o.total FROM products p JOIN orders o ON p.id = o.product_id) SELECT bad FROM cte")))))))
(deftest ^:parallel fallback-zero-tables-test
(testing "CTE with no real table references - error has no source attribution"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(is (= (normalize-error-names driver
#{{:type :missing-column
:name "bad"}})
(deps.native-validation/validate-native-query
driver
(fake-query mp "WITH cte AS (SELECT 1 AS id) SELECT bad FROM cte")))))))
(deftest ^:parallel card-source-attribution-test
(testing "errors are attributed to the correct card"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "mixed table+card, qualified to card - attributed to card"
(validates? mp driver 29
#{(merge (lib/missing-column-error "BAD")
{:source-entity-type :card
:source-entity-id 1})}))
(testing "multi-card, qualified to second card - attributed to card 2"
(validates? mp driver 30
#{(merge (lib/missing-column-error "BAD")
{:source-entity-type :card
:source-entity-id 2})})))))
(deftest ^:parallel transitive-card-chain-test
(testing "Transitive card chain: c32 -> c31 -> c1, where c31's result-metadata lost CATEGORY"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(is (nil? (some #{"CATEGORY"} (map :name (:result-metadata (lib.metadata/card mp 31)))))
"Card 31 should not have CATEGORY in its result-metadata")
(validates? mp driver 32
#{(merge (lib/missing-column-error "CATEGORY")
{:source-entity-type :card
:source-entity-id 31})}))))
(deftest ^:parallel cross-reference-mbql-and-native-cards-test
(testing "Native query referencing an MBQL card"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "error attributed to MBQL card"
(validates? mp driver 34
#{(merge (lib/missing-column-error "CATEGORY")
{:source-entity-type :card
:source-entity-id 33})}))
(testing "mixed MBQL + native card refs - errors attributed to correct cards"
(validates? mp driver 36
#{(merge (lib/missing-column-error "CATEGORY")
{:source-entity-type :card
:source-entity-id 33})
(merge (lib/missing-column-error "BAD")
{:source-entity-type :card
:source-entity-id 35})})))))
(deftest ^:parallel card-with-special-char-column-test
(testing "Card reference where card has special characters in column names"
(let [base-mp (deps.tu/mock-metadata-provider {:snippets []})
driver (:engine (lib.metadata/database base-mp))
card (deps.tu/mock-card base-mp
{:id 100
:query "SELECT 1"
:details {:result-metadata [{:name "FIRST NAME" :base-type :type/Text}
{:name "ORDER-ID" :base-type :type/Integer}
{:name "NORMAL" :base-type :type/Text}]}})
mp (deps.tu/mock-metadata-provider {:cards [card] :snippets []})
mkquery (fn [sql]
(let [ttags (lib/extract-template-tags mp sql)]
(fake-query mp sql ttags)))]
(testing "valid column produces no errors"
(is (= #{}
(deps.native-validation/validate-native-query
driver
(mkquery "SELECT NORMAL FROM {{#100}}")))))
(testing "invalid column produces error attributed to card"
(is (= (normalize-error-names driver
#{(merge (lib/missing-column-error "BAD")
{:source-entity-type :card
:source-entity-id 100})})
(deps.native-validation/validate-native-query
driver
(mkquery "SELECT BAD FROM {{#100}}"))))))))
(deftest ^:parallel placeholder-collision-test
(testing "When SQL contains the placeholder prefix as a real table, validation runs without source attribution"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))
ttags (lib/extract-template-tags mp "SELECT BAD FROM {{#1}}")
query (fake-query mp
"SELECT BAD FROM mb__validat_card__99 JOIN {{#1}}"
ttags)]
(is (= (normalize-error-names driver
#{(lib/missing-column-error "BAD")})
(deps.native-validation/validate-native-query driver query))))))
(deftest ^:parallel placeholder-no-card-refs-test
(testing "Queries with tables named as placeholder produce expected validation results"
(let [mp (lib.tu/mock-metadata-provider
{:database {:id 100 :engine :h2}
:tables [{:name "MB__VALIDAT_CARD__10330"
:schema "PUBLIC"
:id 333
:db-id 1}]
:fields [{:table-id 333
:name "ID"
:base-type :type/Integer}]})
driver (:engine (lib.metadata/database mp))]
(testing "Selection of column present in placeholder named table returns no error"
(let [query (fake-query mp
"SELECT ID FROM MB__VALIDAT_CARD__10330")]
(is (= #{}
(deps.native-validation/validate-native-query driver query)))))
(testing "Selection of column missing from placeholder named table returns an error with source"
(let [query (fake-query mp
"SELECT BAD FROM MB__VALIDAT_CARD__10330")]
(is (= (normalize-error-names driver
#{(merge (lib/missing-column-error "BAD")
{:source-entity-type :table
:source-entity-id 333})})
(deps.native-validation/validate-native-query driver query))))))))
(deftest ^:parallel select-from-non-existent-card-test
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "Select from non-existent card produces an error"
(let [query (fake-query mp
"SELECT ID FROM {{#1234-missing-card}}")]
(is (= (normalize-error-names driver
#{(merge (lib/missing-column-error "ID")
{:source-entity-type :card
:source-entity-id 1234})})
(deps.native-validation/validate-native-query driver query)))))))

View File

@@ -121,5 +121,47 @@
{:id 22
:query "SELECT * FROM {{#1}}"}
{:id 23
:query "SELECT p.LATITUDE FROM {{#1}} AS p"}])
:query "SELECT p.LATITUDE FROM {{#1}} AS p"}
{:id 24
:query "SELECT BAD FROM {{#1}} AS c1 JOIN {{#2}} AS c2 ON c1.ID = c2.ID"}
{:id 25
:query "SELECT c1.BAD FROM {{#1}} AS c1 JOIN {{#2}} AS c2 ON c1.ID = c2.ID"}
{:id 26
:query "SELECT BAD FROM products JOIN {{#1}} AS c1 ON products.ID = c1.ID"}
{:id 27
:query "SELECT products.BAD FROM products JOIN {{#1}} AS c1 ON products.ID = c1.ID"}
{:id 28
:query "SELECT xix.x, products.BAD FROM products JOIN {{#1}} AS c1 ON products.ID = c1.ID"}
;; Mixed table+card, qualified to card alias
{:id 29
:query "SELECT c1.BAD FROM products JOIN {{#1-how-are-you}} AS c1 ON products.ID = c1.ID"}
;; Multi-card, qualified to second card
{:id 30
:query "SELECT c2.BAD FROM {{#1-im-fine}} AS c1 JOIN {{#2-thank-you}} AS c2 ON c1.ID = c2.ID"}
;; Card 31: middle of transitive chain, passes through card 1's columns
;; but with CATEGORY removed (simulates upstream card 1 was changed)
{:id 31
:query "SELECT * FROM {{#1}}"
:details {:result-metadata
(vec (remove #(= (:name %) "CATEGORY")
(:result-metadata initial-card-a)))}}
;; Card 32: end of chain, selects CATEGORY which card 31 no longer has
{:id 32
:query "SELECT CATEGORY FROM {{#31}}"}
;; Card 33: MBQL card with subset of products columns (simulates MBQL intermediary)
{:id 33
:query (lib/query initial-mock-mp (meta/table-metadata :products))
:details {:result-metadata
(vec (filter #(#{"ID" "TITLE"} (:name %))
(:result-metadata initial-card-a)))}}
;; Card 34: native referencing MBQL card 33 — CATEGORY not in card 33's metadata
{:id 34
:query "SELECT CATEGORY FROM {{#33}}"}
;; Card 35: native card with orders columns as result-metadata
{:id 35
:query "SELECT * FROM orders"
:details {:result-metadata (:result-metadata initial-card-b)}}
;; Card 36: native referencing both MBQL card 33 and native card 35
{:id 36
:query "SELECT c1.CATEGORY, c2.BAD FROM {{#33}} AS c1 JOIN {{#35}} AS c2 ON c1.ID = c2.ID"}])
:snippets snippets})))

View File

@@ -3,8 +3,8 @@
[buddy.sign.jwt :as jwt]
[clojure.string :as str]
[clojure.test :refer :all]
[metabase.config.core :as config]
[metabase.request.core :as request]
[metabase.server.instance :as server.instance]
[metabase.session.core :as session]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
@@ -62,7 +62,7 @@
[jwt-enabled true
jwt-identity-provider-uri "http://test.idp.metabase.com"
jwt-shared-secret default-jwt-secret
site-url (format "http://localhost:%s" (config/config-str :mb-jetty-port))]
site-url (format "http://localhost:%s" (server.instance/server-port))]
(f))))
(defn- create-valid-jwt-token

View File

@@ -4,7 +4,7 @@
[clojure.test :refer :all]
[metabase-enterprise.sso.test-setup :as sso.test-setup]
[metabase.auth-identity.core :as auth-identity]
[metabase.config.core :as config]
[metabase.server.instance :as server.instance]
[metabase.sso.oidc.state :as oidc.state]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
@@ -86,7 +86,7 @@
(fn []
(mt/with-temporary-setting-values
[oidc-providers [test-provider]
site-url (format "http://localhost:%s" (config/config-str :mb-jetty-port))]
site-url (format "http://localhost:%s" (server.instance/server-port))]
~@body)))))))))
;;; -------------------------------------------------- Prerequisites Tests --------------------------------------------------
@@ -106,7 +106,7 @@
(mt/with-additional-premium-features #{:sso-oidc}
(mt/with-temporary-setting-values
[oidc-providers [(assoc test-provider :enabled false)]
site-url (format "http://localhost:%s" (config/config-str :mb-jetty-port))]
site-url (format "http://localhost:%s" (server.instance/server-port))]
(with-ensure-encryption!
(with-successful-oidc!
(let [response (mt/client-full-response :get 400 "/auth/sso/test-idp"

View File

@@ -5,9 +5,9 @@
to avoid circular dependencies between test namespaces."
(:require
[clojure.string :as str]
[metabase.config.core :as config]
[metabase.premium-features.token-check :as token-check]
[metabase.request.core :as request]
[metabase.server.instance :as server.instance]
[metabase.test :as mt]
[metabase.util :as u]
[metabase.util.random :as u.random]
@@ -100,7 +100,7 @@
[jwt-enabled true
jwt-identity-provider-uri default-jwt-idp-uri
jwt-shared-secret default-jwt-secret
site-url (format "http://localhost:%s" (config/config-str :mb-jetty-port))]
site-url (format "http://localhost:%s" (server.instance/server-port))]
(mt/with-premium-features current-features
(f))))))
@@ -136,7 +136,7 @@
slack-connect-client-secret default-slack-client-secret
slack-connect-authentication-mode "sso"
slack-connect-user-provisioning-enabled true
site-url (format "http://localhost:%s" (config/config-str :mb-jetty-port))]
site-url (format "http://localhost:%s" (server.instance/server-port))]
(mt/with-premium-features current-features
(f))))))
@@ -158,7 +158,7 @@
(mt/with-additional-premium-features #{:sso-oidc}
(mt/with-temporary-setting-values
[oidc-providers [default-oidc-provider]
site-url (format "http://localhost:%s" (config/config-str :mb-jetty-port))]
site-url (format "http://localhost:%s" (server.instance/server-port))]
(mt/with-premium-features current-features
(f))))))

View File

@@ -11,6 +11,8 @@
(set! *warn-on-reflection* true)
;; Another dummy change to trigger CI
(deftest get-library-path-test
(testing "GET /api/ee/transforms-python/library/:path"
(mt/with-premium-features #{:transforms-python :transforms-basic}

View File

@@ -558,8 +558,9 @@
(transforms.tu/test-run transform-id)
(transforms.tu/wait-for-transform-completion transform-id 10000)
;; hmmm, looks like QP needs a bit more time to update metadata
(Thread/sleep 2000)
;; Sync runs asynchronously after succeed-started-run!, so wait for
;; the new "friend" field to appear in metadata before querying.
(transforms.tu/wait-for-field table-name "friend" 10000)
(let [updated-rows (transforms.tu/table-rows table-name)]
(is (= [["Alice" "Bob"] ["Bob" "Alice"]] updated-rows)
"Updated data should show Alice/Bob with friends instead of ages"))))))))))

View File

@@ -1,20 +1,49 @@
import { t } from "ttag";
import { useUpdateCardMutation } from "metabase/api";
import {
useGetDatabaseQuery,
useGetTableQuery,
useUpdateCardMutation,
} from "metabase/api";
import { DateTime } from "metabase/common/components/DateTime";
import { EditableText } from "metabase/common/components/EditableText";
import { Link } from "metabase/common/components/Link/Link";
import { Markdown } from "metabase/common/components/Markdown";
import * as Urls from "metabase/lib/urls";
import { getUserName } from "metabase/lib/user";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { Box } from "metabase/ui";
import type { Card, CardType } from "metabase-types/api";
import { PLUGIN_DEPENDENCIES } from "metabase/plugins";
import { Box, Card, Flex, Group, Icon, Stack, Text, rem } from "metabase/ui";
import type { Card as CardApiType, CardType } from "metabase-types/api";
import S from "./DescriptionSection.module.css";
type DescriptionSectionProps = {
card: Card;
card: CardApiType;
};
export function DescriptionSection({ card }: DescriptionSectionProps) {
const [updateCard] = useUpdateCardMutation();
const { sendSuccessToast, sendErrorToast } = useMetadataToasts();
const isDependenciesEnabled = PLUGIN_DEPENDENCIES.isEnabled;
const { data: database } = useGetDatabaseQuery(
{ id: card.database_id! },
{ skip: !card.database_id },
);
const { data: table } = useGetTableQuery(
{ id: card.table_id! },
{ skip: !card.table_id },
);
const { dependentsCount, dependenciesCount } =
PLUGIN_DEPENDENCIES.useGetDependenciesCount({
id: Number(card.id),
type: "card",
});
const handleChange = async (newValue: string) => {
const newDescription = newValue.trim();
const { error } = await updateCard({
@@ -28,25 +57,123 @@ export function DescriptionSection({ card }: DescriptionSectionProps) {
}
};
if (card.can_write) {
return (
<Box>
<EditableText
initialValue={card.description ?? ""}
placeholder={t`No description`}
isMarkdown
onChange={handleChange}
px={0}
/>
return (
<Stack gap={0} align="stretch" data-testid="metric-description-sidebar">
{/* Description */}
<Box data-testid="metric-description-section" p={rem(20)}>
{card.can_write ? (
<EditableText
initialValue={card.description ?? ""}
placeholder={t`No description`}
isMarkdown
isOptional
isMultiline
onChange={handleChange}
px={0}
/>
) : (
<Markdown>{card.description || t`No description`}</Markdown>
)}
</Box>
);
}
if (card.description) {
return <Markdown>{card.description}</Markdown>;
}
{/* Metadata Sections */}
<Card mx="lg" bg="background-secondary" shadow="none" radius="1rem">
<Card.Section withBorder p="md">
<Group gap="sm" mb={4}>
<Icon name="pencil" c="brand" />
<Text size="md" fw={600} lh="1rem">
<DateTime value={card.updated_at} />
</Text>
</Group>
<Text size="sm" c="text-secondary" lh="1rem" ml="1.5rem">
{card["last-edit-info"]
? t`Last edited by ${getUserName(card["last-edit-info"])}`
: t`Last edited at`}
</Text>
</Card.Section>
<Card.Section withBorder p="md">
<Group gap="sm" mb={4}>
<Icon name="database" c="brand" />
return <Box c="text-secondary" lh="h4">{t`No description`}</Box>;
{database ? (
<Link to={Urls.dataStudioData({ databaseId: database.id })}>
<Text size="md" fw={600} lh="1rem">
{database.name}
</Text>
</Link>
) : (
<Text size="md" fw={600} lh="1rem">
—
</Text>
)}
</Group>
<Text size="sm" c="text-secondary" lh="1rem" ml="1.5rem">
{t`Database`}
</Text>
</Card.Section>
<Card.Section withBorder p="md">
<Group gap="sm" mb={4}>
<Icon name="table" c={table ? "brand" : "icon-secondary"} />
<Text size="md" fw={600} lh="1rem">
{table?.display_name || table?.name || "—"}
</Text>
</Group>
<Text size="sm" c="text-secondary" lh="1rem" ml="1.5rem">
{t`Source table`}
</Text>
</Card.Section>
</Card>
{/* Statistics */}
{isDependenciesEnabled && (
<Card mx="lg" my="lg" shadow="none">
<Card.Section withBorder py={rem(12)} px="md">
<Flex justify="space-between" align="center">
<Text size="md" c="text-secondary">
{t`Dependencies`}
</Text>
{dependenciesCount > 0 ? (
<Link
to={Urls.dataStudioMetricDependencies(card.id)}
className={S.metricLink}
>
<Text size="xl" fw={600}>
{dependenciesCount}
</Text>
</Link>
) : (
<Text size="xl" fw={600}>
{dependenciesCount}
</Text>
)}
</Flex>
</Card.Section>
<Card.Section withBorder py={rem(12)} px="md">
<Flex justify="space-between" align="center">
<Text size="md" c="text-secondary">
{t`Dependents`}
</Text>
{dependentsCount > 0 ? (
<Link
to={Urls.dataStudioMetricDependencies(card.id)}
className={S.metricLink}
>
<Text size="xl" fw={600}>
{dependentsCount}
</Text>
</Link>
) : (
<Text size="xl" fw={600}>
{dependentsCount}
</Text>
)}
</Flex>
</Card.Section>
</Card>
)}
</Stack>
);
}
function getSuccessMessage(cardType: CardType) {

View File

@@ -2,6 +2,11 @@
overflow: auto;
}
.sidebar {
flex-shrink: 0;
.descriptionSection {
background-color: var(--mb-color-background-primary);
border: 1px solid var(--mb-color-border);
border-left: none;
border-top-right-radius: var(--default-border-radius);
border-bottom-right-radius: var(--default-border-radius);
overflow: auto;
}

View File

@@ -1,11 +1,9 @@
import { EntityCreationInfo } from "metabase/common/components/EntityCreationInfo";
import { OverviewVisualization } from "metabase/data-studio/common/components/OverviewVisualization";
import { Flex, Stack } from "metabase/ui";
import type { Card } from "metabase-types/api";
import { DescriptionSection } from "./DescriptionSection";
import S from "./MetricOverview.module.css";
import { QuerySourceSection } from "./QuerySourceSection";
type MetricOverviewProps = {
card: Card;
@@ -17,15 +15,8 @@ export function MetricOverview({ card }: MetricOverviewProps) {
<Flex direction="column" flex={1} mah={700}>
<OverviewVisualization card={card} />
</Flex>
<Stack w={300} ml="lg" gap="lg" className={S.sidebar}>
<Stack flex="0 0 360px" className={S.descriptionSection} mah={700}>
<DescriptionSection card={card} />
<QuerySourceSection card={card} />
<EntityCreationInfo
createdAt={card.created_at}
creator={card.creator}
lastEditedAt={card["last-edit-info"]?.timestamp}
lastEditor={card["last-edit-info"]}
/>
</Stack>
</Flex>
);

View File

@@ -1,6 +0,0 @@
.link {
&:hover {
text-decoration: none;
color: var(--mb-color-brand);
}
}

View File

@@ -1,174 +0,0 @@
import { Fragment, useMemo } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import { useGetCardQuery, useGetTableQuery } from "metabase/api";
import { useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { getMetadata } from "metabase/selectors/metadata";
import { Group, Stack, Title } from "metabase/ui";
import * as Lib from "metabase-lib";
import type { Card, CardId, TableId } from "metabase-types/api";
import S from "./QuerySourceInfo.module.css";
type QuerySourceSectionProps = {
card: Card;
};
export function QuerySourceSection({ card }: QuerySourceSectionProps) {
const metadata = useSelector(getMetadata);
const { query, queryInfo } = useMemo(() => {
const query = Lib.fromJsQueryAndMetadata(metadata, card.dataset_query);
const queryInfo = Lib.queryDisplayInfo(query);
return { query, queryInfo };
}, [metadata, card]);
const pickerInfo = useMemo(() => {
const tableOrCardId = Lib.sourceTableOrCardId(query);
const tableOrCard =
tableOrCardId != null
? Lib.tableOrCardMetadata(query, tableOrCardId)
: null;
return tableOrCard != null ? Lib.pickerInfo(query, tableOrCard) : null;
}, [query]);
if (!queryInfo.isEditable || pickerInfo == null) {
return null;
}
return (
<Stack gap="sm">
<Title order={6}>{t`Based on`}</Title>
{pickerInfo.cardId == null ? (
<TableSourcePath tableId={pickerInfo.tableId} />
) : (
<CardSourcePath cardId={pickerInfo.cardId} />
)}
</Stack>
);
}
type TableSourcePathProps = {
tableId: TableId;
};
function TableSourcePath({ tableId }: TableSourcePathProps) {
const { data: table } = useGetTableQuery({ id: tableId });
const parts = useMemo(() => {
if (!table || !table.db) {
return null;
}
const pathParts: SourcePathPart[] = [];
pathParts.push({
name: table.db.name,
url: table.db.is_audit
? undefined
: Urls.dataStudioData({ databaseId: table.db_id }),
});
if (table.schema) {
pathParts.push({
name: table.schema,
url: table.db.is_audit
? undefined
: Urls.dataStudioData({
databaseId: table.db_id,
schemaName: table.schema,
}),
});
}
pathParts.push({
name: table.display_name,
url: table.db.is_audit
? undefined
: Urls.dataStudioData({
databaseId: table.db_id,
schemaName: table.schema,
tableId: table.id,
}),
});
return pathParts;
}, [table]);
if (!parts) {
return null;
}
return <SourcePath parts={parts} />;
}
type CardSourcePathProps = {
cardId: CardId;
};
function CardSourcePath({ cardId }: CardSourcePathProps) {
const { data: card } = useGetCardQuery({ id: cardId });
const parts = useMemo(() => {
if (card == null) {
return null;
}
const pathParts: SourcePathPart[] = [
{ name: card.name, url: Urls.question(card) },
];
const collection = card.collection;
if (collection != null) {
pathParts.unshift({
name: collection.name,
url: Urls.collection(collection),
});
if (collection.effective_ancestors != null) {
pathParts.unshift(
...collection.effective_ancestors.map((parent) => ({
name: parent.name,
url: Urls.collection(parent),
})),
);
}
}
return pathParts;
}, [card]);
if (!parts) {
return null;
}
return <SourcePath parts={parts} />;
}
type SourcePathPart = {
name: string;
url?: string;
};
type SourcePathProps = {
parts: SourcePathPart[];
};
function SourcePath({ parts }: SourcePathProps) {
return (
<Group gap="xs">
{parts.map((part, index) => (
<Fragment key={index}>
{index > 0 && " / "}
{part.url ? (
<Link className={S.link} to={part.url}>
{part.name}
</Link>
) : (
<span>{part.name}</span>
)}
</Fragment>
))}
</Group>
);
}

View File

@@ -0,0 +1,67 @@
.gridLabelInput {
.label {
font-weight: normal;
color: var(--mb-color-text-secondary);
}
.input {
width: 11rem;
border-radius: 1.25rem;
&.unset {
color: var(--mb-color-text-secondary);
}
}
.sectionLabel {
&[data-position="right"] {
color: var(--mb-color-text-primary);
}
&[data-position="left"] {
color: var(--mb-color-brand);
}
}
}
.textStyleWrapper {
flex-grow: 1;
}
.textStyleInput {
padding: 0;
border: 0;
background: transparent;
height: 1rem;
min-height: 1rem;
cursor: pointer;
&[data-expanded="true"] {
cursor: text;
}
}
.selectedInput {
font-weight: bold;
}
.textStyleSection {
width: 1rem;
height: 1rem;
cursor: pointer;
&[data-position="left"] {
display: none;
}
svg {
width: 0.75rem !important;
height: 0.75rem !important;
}
}
.metricLink {
:hover {
color: var(--mb-color-brand);
}
}

View File

@@ -1,10 +1,34 @@
import cx from "classnames";
import { t } from "ttag";
import { useUpdateTableMutation } from "metabase/api";
import { DateTime } from "metabase/common/components/DateTime";
import { EditableText } from "metabase/common/components/EditableText";
import { Link } from "metabase/common/components/Link/Link";
import { useNumberFormatter } from "metabase/common/hooks/use-number-formatter";
import { isNullOrUndefined } from "metabase/lib/types";
import * as Urls from "metabase/lib/urls";
import {
DataSourceInput,
EntityTypeInput,
UserInput,
} from "metabase/metadata/components";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { Box } from "metabase/ui";
import type { Table } from "metabase-types/api";
import { PLUGIN_DEPENDENCIES } from "metabase/plugins";
import {
Box,
Card,
Divider,
Flex,
Group,
Icon,
Stack,
Text,
rem,
} from "metabase/ui";
import type { Table, TableDataSource, UserId } from "metabase-types/api";
import S from "./DescriptionSection.module.css";
type DescriptionSectionProps = {
table: Table;
@@ -12,7 +36,16 @@ type DescriptionSectionProps = {
export function DescriptionSection({ table }: DescriptionSectionProps) {
const [updateTable] = useUpdateTableMutation();
const { sendSuccessToast, sendErrorToast } = useMetadataToasts();
const { sendErrorToast, sendSuccessToast, sendUndoToast } =
useMetadataToasts();
const formatNumber = useNumberFormatter();
const isDependenciesEnabled = PLUGIN_DEPENDENCIES.isEnabled;
const { dependentsCount } = PLUGIN_DEPENDENCIES.useGetDependenciesCount({
id: Number(table.id),
type: "table",
});
const handleChange = async (newValue: string) => {
const newDescription = newValue.trim();
@@ -27,14 +60,276 @@ export function DescriptionSection({ table }: DescriptionSectionProps) {
}
};
const handleDataSourceChange = async (
dataSource: TableDataSource | "unknown" | null,
) => {
if (dataSource == null) {
return; // should never happen as the input is not clearable here
}
const { error } = await updateTable({
id: table.id,
data_source: dataSource === "unknown" ? null : dataSource,
});
if (error) {
sendErrorToast(t`Failed to update table data source`);
} else {
sendSuccessToast(t`Table data source updated`, async () => {
const { error } = await updateTable({
id: table.id,
data_source: table.data_source,
});
sendUndoToast(error);
});
}
};
const handleEntityTypeChange = async (entityType: Table["entity_type"]) => {
const { error } = await updateTable({
id: table.id,
entity_type: entityType,
});
if (error) {
sendErrorToast(t`Failed to update entity type`);
} else {
sendSuccessToast(t`Entity type updated`, async () => {
const { error } = await updateTable({
id: table.id,
entity_type: table.entity_type,
});
sendUndoToast(error);
});
}
};
const handleOwnerEmailChange = async (email: string | null) => {
const { error } = await updateTable({
id: table.id,
owner_email: email,
owner_user_id: null,
});
if (error) {
sendErrorToast(t`Failed to update table owner`);
} else {
sendSuccessToast(t`Table owner updated`, async () => {
const { error } = await updateTable({
id: table.id,
owner_email: table.owner_email,
owner_user_id: table.owner_user_id,
});
sendUndoToast(error);
});
}
};
const handleOwnerUserIdChange = async (userId: UserId | "unknown" | null) => {
if (userId == null) {
return; // should never happen as the input is not clearable here
}
const { error } = await updateTable({
id: table.id,
owner_email: null,
owner_user_id: userId === "unknown" ? null : userId,
});
if (error) {
sendErrorToast(t`Failed to update table owner`);
} else {
sendSuccessToast(t`Table owner updated`, async () => {
const { error } = await updateTable({
id: table.id,
owner_email: table.owner_email,
owner_user_id: table.owner_user_id,
});
sendUndoToast(error);
});
}
};
const isOwnerSpecified = table.owner_email || table.owner_user_id;
return (
<Box data-testid="table-description-section">
<EditableText
initialValue={table.description ?? ""}
placeholder={t`No description`}
isMarkdown
onChange={handleChange}
/>
</Box>
<Stack gap={0} align="stretch" data-testid="table-description-sidebar">
{/* Entity Type Selector */}
<Flex justify="space-between" align="center" px="lg" py="md">
<Text size="md" c="text-secondary">
{t`Entity type`}
</Text>
<EntityTypeInput
value={table.entity_type ?? "entity/GenericTable"}
label={null}
onChange={handleEntityTypeChange}
classNames={{
input: S.input,
label: S.label,
section: table.entity_type ? S.sectionLabel : "",
}}
className={S.gridLabelInput}
/>
</Flex>
<Divider />
{/* Description */}
<Box data-testid="table-description-section" p={rem(20)}>
<EditableText
initialValue={table.description ?? ""}
placeholder={t`No description`}
isMarkdown
isOptional
isMultiline
onChange={handleChange}
/>
</Box>
{/* Metadata Sections */}
<Card mx="lg" bg="background-secondary" shadow="none" radius="1rem">
<Card.Section withBorder p="md">
<Group gap="sm" mb={4}>
<Icon name="pencil" c="brand" />
<Text size="md" fw={600} lh="1rem">
<DateTime value={table.updated_at} />
</Text>
</Group>
<Text size="sm" c="text-secondary" lh="1rem" ml="1.5rem">
{t`Last edited at`}
</Text>
</Card.Section>
<Card.Section withBorder p="md">
<Group gap="sm" mb={4}>
<Icon name="database" c="brand" />
{table.db ? (
<Link to={Urls.dataStudioData({ databaseId: table.db.id })}>
<Text size="md" fw={600} lh="1rem">
{table.db.name}
</Text>
</Link>
) : (
<Text size="md" fw={600} lh="1rem">
—
</Text>
)}
</Group>
<Text size="sm" c="text-secondary" lh="1rem" ml="1.5rem">
{t`Database`}
</Text>
</Card.Section>
<Card.Section withBorder p="md">
<Group gap="sm" mb={4}>
<Icon
name="refresh_downstream"
c={table.data_source ? "brand" : "icon-secondary"}
/>
<DataSourceInput
disabled={table.data_source === "metabase-transform"}
value={table.data_source ?? "unknown"}
label={null}
classNames={{
root: S.textStyleWrapper,
input: cx(
S.textStyleInput,
table.data_source && S.selectedInput,
),
section: S.textStyleSection,
}}
showUnknown={!table.data_source}
onChange={handleDataSourceChange}
/>
</Group>
<Text size="sm" c="text-secondary" lh="1rem" ml="1.5rem">
{t`Source`}
</Text>
</Card.Section>
<Card.Section withBorder p="md">
<Group gap="sm" mb={4}>
<Icon
name="person"
c={isOwnerSpecified ? "brand" : "icon-secondary"}
/>
<UserInput
email={table.owner_email}
userId={isOwnerSpecified ? table.owner_user_id : "unknown"}
onEmailChange={handleOwnerEmailChange}
onUserIdChange={handleOwnerUserIdChange}
unknownUserLabel={t`No owner`}
classNames={{
root: S.textStyleWrapper,
input: cx(
S.textStyleInput,
isOwnerSpecified && S.selectedInput,
),
section: S.textStyleSection,
}}
/>
</Group>
<Text size="sm" c="text-secondary" lh="1rem" ml="1.5rem">
{t`Owner`}
</Text>
</Card.Section>
</Card>
{/* Statistics */}
<Card mx="lg" my="lg" shadow="none">
<Card.Section withBorder py={rem(12)} px="md">
<Flex justify="space-between" align="center">
<Text size="md" c="text-secondary">
{t`Fields`}
</Text>
<Link
to={Urls.dataStudioTableFields(table.id)}
className={S.metricLink}
>
<Text size="xl" fw={600}>
{table.fields?.length ?? 0}
</Text>
</Link>
</Flex>
</Card.Section>
{!isNullOrUndefined(table.estimated_row_count) && (
<Card.Section withBorder py={rem(12)} px="md">
<Flex justify="space-between" align="center">
<Text size="md" c="text-secondary">
{t`Rows`}
</Text>
<Text size="xl" fw={600}>
{formatNumber(table.estimated_row_count)}
</Text>
</Flex>
</Card.Section>
)}
{isDependenciesEnabled && (
<Card.Section withBorder py={rem(12)} px="md">
<Flex justify="space-between" align="center">
<Text size="md" c="text-secondary">
{t`Dependents`}
</Text>
{dependentsCount > 0 ? (
<Link
to={Urls.dataStudioTableDependencies(table.id)}
className={S.metricLink}
>
<Text size="xl" fw={600}>
{dependentsCount}
</Text>
</Link>
) : (
<Text size="xl" fw={600}>
{dependentsCount}
</Text>
)}
</Flex>
</Card.Section>
)}
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,8 @@
.descriptionSection {
background-color: var(--mb-color-background-primary);
border: 1px solid var(--mb-color-border);
border-left: none;
border-top-right-radius: var(--default-border-radius);
border-bottom-right-radius: var(--default-border-radius);
overflow: auto;
}

View File

@@ -10,6 +10,7 @@ import type Metadata from "metabase-lib/v1/metadata/Metadata";
import type { Table } from "metabase-types/api";
import { DescriptionSection } from "./DescriptionSection";
import S from "./TableOverview.module.css";
type TableOverviewProps = {
table: Table;
@@ -19,11 +20,11 @@ export function TableOverview({ table }: TableOverviewProps) {
const metadata = useSelector(getMetadata);
const card = useMemo(() => getCard(table, metadata), [table, metadata]);
return (
<Flex flex={1} gap="lg">
<Flex flex={1} gap={0}>
<Flex direction="column" flex={1} mah={700}>
{card && <OverviewVisualization card={card} />}
</Flex>
<Stack flex="0 0 300px">
<Stack flex="0 0 360px" className={S.descriptionSection} mah={700}>
<DescriptionSection table={table} />
</Stack>
</Flex>

View File

@@ -4,9 +4,6 @@ import { hasPremiumFeature } from "metabase-enterprise/settings";
import { CheckDependenciesForm } from "./components/CheckDependenciesForm";
import { CheckDependenciesModal } from "./components/CheckDependenciesModal";
import { CheckDependenciesTitle } from "./components/CheckDependenciesTitle";
import { useCheckCardDependencies } from "./hooks/use-check-card-dependencies";
import { useCheckSnippetDependencies } from "./hooks/use-check-snippet-dependencies";
import { useCheckTransformDependencies } from "./hooks/use-check-transform-dependencies";
import { useGetDependenciesCount } from "./hooks/use-get-dependencies-count";
import { DependencyGraphPage } from "./pages/DependencyGraphPage";
import {
@@ -28,11 +25,6 @@ export function initializePlugin() {
PLUGIN_DEPENDENCIES.CheckDependenciesForm = CheckDependenciesForm;
PLUGIN_DEPENDENCIES.CheckDependenciesModal = CheckDependenciesModal;
PLUGIN_DEPENDENCIES.CheckDependenciesTitle = CheckDependenciesTitle;
PLUGIN_DEPENDENCIES.useCheckCardDependencies = useCheckCardDependencies;
PLUGIN_DEPENDENCIES.useCheckSnippetDependencies =
useCheckSnippetDependencies;
PLUGIN_DEPENDENCIES.useCheckTransformDependencies =
useCheckTransformDependencies;
PLUGIN_DEPENDENCIES.useGetDependenciesCount = useGetDependenciesCount;
}
}

View File

@@ -12,7 +12,6 @@ import { ForwardRefLink } from "metabase/common/components/Link";
import { UserAvatar } from "metabase/common/components/UserAvatar";
import CS from "metabase/css/core/index.css";
import { useDispatch } from "metabase/lib/redux";
import { regexpEscape } from "metabase/lib/string";
import {
Box,
Button,
@@ -57,8 +56,11 @@ export const TenantsListing = ({
};
const filteredTenants = useMemo(() => {
const filter = new RegExp(`\\b${regexpEscape(searchInputValue)}`, "i");
return tenants.filter((g) => filter.test(g.name));
if (!searchInputValue) {
return tenants;
}
const lowerSearch = searchInputValue.toLowerCase();
return tenants.filter((g) => g.name.toLowerCase().includes(lowerSearch));
}, [searchInputValue, tenants]);
if (hasNoTenants) {

View File

@@ -0,0 +1,75 @@
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { renderWithProviders, screen } from "__support__/ui";
import { ACTIVE_STATUS } from "metabase/admin/people/constants";
import { createMockTenant } from "metabase-types/api/mocks";
import { TenantsListing } from "./TenantsListing";
const TenantsListingWrapper = ({
tenants = [createMockTenant()],
}: {
tenants?: ReturnType<typeof createMockTenant>[];
} = {}) => {
const [searchInputValue, setSearchInputValue] = useState("");
return (
<TenantsListing
tenants={tenants}
isAdmin={true}
searchInputValue={searchInputValue}
setSearchInputValue={setSearchInputValue}
status={ACTIVE_STATUS.active}
hasNoTenants={tenants.length === 0}
/>
);
};
const setup = (tenants = [createMockTenant()]) => {
renderWithProviders(<TenantsListingWrapper tenants={tenants} />);
};
describe("TenantsListing", () => {
it("shows tenants with non-Latin names", () => {
setup([createMockTenant({ id: 10, name: "здравей", slug: "zdravey" })]);
expect(screen.getByText("здравей")).toBeInTheDocument();
});
it("shows tenants with non-Latin names alongside Latin-named tenants", () => {
setup([
createMockTenant({ id: 10, name: "здравей", slug: "zdravey" }),
createMockTenant({ id: 11, name: "Acme Corp", slug: "acme-corp" }),
]);
expect(screen.getByText("здравей")).toBeInTheDocument();
expect(screen.getByText("Acme Corp")).toBeInTheDocument();
});
it("filters non-Latin tenants by search text", async () => {
setup([
createMockTenant({ id: 10, name: "здравей", slug: "zdravey" }),
createMockTenant({ id: 11, name: "Acme Corp", slug: "acme-corp" }),
]);
const searchInput = screen.getByPlaceholderText("Find a tenant");
await userEvent.type(searchInput, "здрав");
expect(screen.getByText("здравей")).toBeInTheDocument();
expect(screen.queryByText("Acme Corp")).not.toBeInTheDocument();
});
it("excludes non-matching non-Latin tenants when searching", async () => {
setup([
createMockTenant({ id: 10, name: "здравей", slug: "zdravey" }),
createMockTenant({ id: 11, name: "Acme Corp", slug: "acme-corp" }),
]);
const searchInput = screen.getByPlaceholderText("Find a tenant");
await userEvent.type(searchInput, "Acme");
expect(screen.queryByText("здравей")).not.toBeInTheDocument();
expect(screen.getByText("Acme Corp")).toBeInTheDocument();
});
});

View File

@@ -385,6 +385,7 @@ export const createMockSettings = (
"subscription-allowed-domains": null,
"token-features": createMockTokenFeatures(),
"token-status": null,
"tracing-enabled": false,
"transforms-enabled": false,
version: createMockVersion(),
"version-info": createMockVersionInfo(),

View File

@@ -599,6 +599,7 @@ interface PublicSettings {
"snowplow-url": string;
"start-of-week": DayOfWeekId;
"token-features": TokenFeatures;
"tracing-enabled": boolean;
"transforms-enabled": boolean;
version: Version;
"version-info-last-checked": string | null;

View File

@@ -18,7 +18,6 @@ import {
isDefaultGroup,
} from "metabase/lib/groups";
import { KEYCODE_ENTER } from "metabase/lib/keyboard";
import { regexpEscape } from "metabase/lib/string";
import { PLUGIN_TENANTS } from "metabase/plugins";
import {
Box,
@@ -497,8 +496,11 @@ export const GroupsListing = (props: GroupsListingProps) => {
const { groups, isAdmin } = props;
const groupNameFilter = new RegExp(`\\b${regexpEscape(searchText)}`, "i");
const filteredGroups = groups.filter((g) => groupNameFilter.test(g.name));
const filteredGroups = searchText
? groups.filter((g) =>
g.name.toLowerCase().includes(searchText.toLowerCase()),
)
: groups;
return (
<AdminPaneLayout

View File

@@ -0,0 +1,69 @@
import userEvent from "@testing-library/user-event";
import { setupApiKeyEndpoints } from "__support__/server-mocks";
import { renderWithProviders, screen } from "__support__/ui";
import { createMockGroup } from "metabase-types/api/mocks";
import { GroupsListing } from "./GroupsListing";
const setup = (groups = [createMockGroup()]) => {
setupApiKeyEndpoints([]);
const props = {
groups: groups.map((g) => ({ ...g, members: [] })),
isAdmin: true,
create: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
};
renderWithProviders(<GroupsListing {...props} />);
};
describe("GroupsListing", () => {
it("shows groups with non-Latin names", async () => {
setup([
createMockGroup({ id: 10, name: "здравей", magic_group_type: null }),
]);
expect(await screen.findByText("здравей")).toBeInTheDocument();
});
it("shows groups with non-Latin names alongside Latin-named groups", async () => {
setup([
createMockGroup({ id: 10, name: "здравей", magic_group_type: null }),
createMockGroup({ id: 11, name: "Engineering", magic_group_type: null }),
]);
expect(await screen.findByText("здравей")).toBeInTheDocument();
expect(await screen.findByText("Engineering")).toBeInTheDocument();
});
it("filters non-Latin groups by search text", async () => {
setup([
createMockGroup({ id: 10, name: "здравей", magic_group_type: null }),
createMockGroup({ id: 11, name: "Engineering", magic_group_type: null }),
]);
await screen.findByText("здравей");
const searchInput = screen.getByPlaceholderText("Find a group");
await userEvent.type(searchInput, "здрав");
expect(screen.getByText("здравей")).toBeInTheDocument();
expect(screen.queryByText("Engineering")).not.toBeInTheDocument();
});
it("excludes non-matching non-Latin groups when searching", async () => {
setup([
createMockGroup({ id: 10, name: "здравей", magic_group_type: null }),
createMockGroup({ id: 11, name: "Engineering", magic_group_type: null }),
]);
await screen.findByText("здравей");
const searchInput = screen.getByPlaceholderText("Find a group");
await userEvent.type(searchInput, "Eng");
expect(screen.queryByText("здравей")).not.toBeInTheDocument();
expect(screen.getByText("Engineering")).toBeInTheDocument();
});
});

View File

@@ -39,6 +39,7 @@ import { createTracker } from "metabase/lib/analytics";
import api from "metabase/lib/api";
import { initializeEmbedding } from "metabase/lib/embed";
import { captureConsoleErrors } from "metabase/lib/errors";
import { initTracing, rotateTraceId } from "metabase/lib/otel";
import { MetabaseReduxProvider } from "metabase/lib/redux/custom-context";
import MetabaseSettings from "metabase/lib/settings";
import { PLUGIN_APP_INIT_FUNCTIONS, PLUGIN_METABOT } from "metabase/plugins";
@@ -72,6 +73,15 @@ function _init(reducers, getRoutes, callback) {
createTracker(store);
// Initialize distributed tracing if enabled via MB_TRACING_ENABLED.
// Uses bootstrap data so it's available before the first API call.
if (window.MetabaseBootstrap?.["tracing-enabled"]) {
initTracing();
// Rotate trace ID on route changes so all API calls within
// a single page view share one trace.
syncedHistory.listen(() => rotateTraceId());
}
initializeEmbedding(store);
const root = createRoot(document.getElementById("root"));

View File

@@ -447,6 +447,44 @@ export const fetchCardData =
);
};
// Leave 1 connection free for interactive requests (browser HTTP/1.1 limit is 6)
const HTTP1_CONCURRENT_CARD_FETCH_LIMIT = 5;
function getCardsFetchingConcurrencyLimit(): number {
try {
const [navigationEntry] = performance.getEntriesByType(
"navigation",
) as PerformanceNavigationTiming[];
const protocol = navigationEntry?.nextHopProtocol ?? "";
// HTTP/2 and HTTP/3 multiplex requests over a single connection,
// so the per-host connection limit does not apply
if (protocol === "h2" || protocol === "h2c" || protocol.startsWith("h3")) {
return Infinity;
}
} catch {
// Performance API unavailable; fall through to the conservative limit
}
return HTTP1_CONCURRENT_CARD_FETCH_LIMIT;
}
const CONCURRENT_CARD_FETCH_LIMIT = getCardsFetchingConcurrencyLimit();
async function runWithConcurrencyLimit(
tasks: (() => Promise<void>)[],
limit: number,
): Promise<void> {
let index = 0;
async function worker() {
while (index < tasks.length) {
const taskIndex = index++;
await tasks[taskIndex]();
}
}
await Promise.all(
Array.from({ length: Math.min(limit, tasks.length) }, worker),
);
}
export const fetchDashboardCardData =
({ isRefreshing = false, reload = false, clearCache = false } = {}) =>
(dispatch: Dispatch, getState: GetState) => {
@@ -499,20 +537,21 @@ export const fetchDashboardCardData =
);
}
const promises = nonVirtualDashcardsToFetch.map(
async ({ card, dashcard }) => {
await dispatch(
// TODO: fix the return type of getAllDashboardCards to make sure
// that the relationship between a dashcard and its card
// is actually reflected in the type system
fetchCardData(card as Card, dashcard, {
reload,
clearCache,
dashboardLoadId,
}),
);
await dispatch(updateLoadingTitle(nonVirtualDashcardsToFetch.length));
},
const tasks = nonVirtualDashcardsToFetch.map(
({ card, dashcard }) =>
async () => {
await dispatch(
// TODO: fix the return type of getAllDashboardCards to make sure
// that the relationship between a dashcard and its card
// is actually reflected in the type system
fetchCardData(card as Card, dashcard, {
reload,
clearCache,
dashboardLoadId,
}),
);
await dispatch(updateLoadingTitle(nonVirtualDashcardsToFetch.length));
},
);
if (nonVirtualDashcardsToFetch.length > 0) {
@@ -522,9 +561,11 @@ export const fetchDashboardCardData =
// TODO: There is a race condition here, when refreshing a dashboard before
// the previous API calls finished.
return Promise.all(promises).then(() => {
dispatch(loadingComplete());
});
return runWithConcurrencyLimit(tasks, CONCURRENT_CARD_FETCH_LIMIT).then(
() => {
dispatch(loadingComplete());
},
);
}
};
@@ -536,10 +577,10 @@ export const reloadDashboardCards =
return;
}
const reloads = getAllDashboardCards(dashboard)
const reloadTasks = getAllDashboardCards(dashboard)
.filter(({ dashcard }) => !isVirtualDashCard(dashcard))
.map(({ card, dashcard }) =>
dispatch(
.map(({ card, dashcard }) => async () => {
await dispatch(
// TODO: fix the return type of getAllDashboardCards to make sure
// that the relationship between a dashcard and its card
// is actually reflected in the type system
@@ -547,10 +588,10 @@ export const reloadDashboardCards =
reload: true,
ignoreCache: true,
}),
),
);
);
});
await Promise.all(reloads);
await runWithConcurrencyLimit(reloadTasks, CONCURRENT_CARD_FETCH_LIMIT);
};
export const cancelFetchDashboardCardData = createThunkAction(

View File

@@ -10,22 +10,28 @@ import { createMockEntitiesState } from "__support__/store";
import { Api } from "metabase/api";
import type { Dashboard } from "metabase-types/api";
import {
createMockCard,
createMockDashboard,
createMockDashboardCard,
createMockDashboardQueryMetadata,
createMockSettings,
} from "metabase-types/api/mocks";
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
import type { DashboardState } from "metabase-types/store";
import type { DashboardState, State } from "metabase-types/store";
import {
createMockDashboardState,
createMockSettingsState,
createMockStoreDashboard,
} from "metabase-types/store/mocks";
import { dashboardReducers } from "../reducers";
import { isQuestionDashCard } from "../utils";
import { fetchCardDataAction, fetchDashboard } from "./data-fetching";
import {
fetchCardDataAction,
fetchDashboard,
fetchDashboardCardData,
reloadDashboardCards,
} from "./data-fetching";
type SetupOpts = {
dashboards?: Dashboard[];
@@ -42,7 +48,7 @@ function setup({
entities: createMockEntitiesState({
databases: [database],
}),
settings: createMockSettings(),
settings: createMockSettingsState(),
};
const store = getStore(
@@ -179,3 +185,95 @@ describe("fetchDashboard", () => {
expect(thirdResult.payload).toMatchObject({ result: { foo: true } });
});
});
/**
* Creates a mock dispatch/getState pair that executes thunks but skips the
* Redux store, avoiding the Immer/icepick incompatibility in dashcardData
* reducer when multiple fulfilled actions arrive.
*/
function createMockDispatch(getState: () => Partial<State>) {
const dispatch = (action: unknown): unknown => {
if (typeof action === "function") {
return action(dispatch, getState);
}
return Promise.resolve(action);
};
return dispatch;
}
function setupConcurrencyTest(dashboardId: number, cardCount: number) {
let currentConcurrent = 0;
let maxConcurrent = 0;
const database = createSampleDatabase();
const dashcards = Array.from({ length: cardCount }, (_, i) =>
createMockDashboardCard({
id: i + 1,
card_id: i + 1,
dashboard_id: dashboardId,
card: createMockCard({ id: i + 1 }),
}),
);
dashcards.forEach((dashcard) => {
fetchMock.post(
`/api/dashboard/${dashboardId}/dashcard/${dashcard.id}/card/${dashcard.card_id}/query`,
() =>
new Promise((resolve) => {
currentConcurrent++;
maxConcurrent = Math.max(maxConcurrent, currentConcurrent);
setTimeout(() => {
currentConcurrent--;
resolve({ data: [] });
}, 50);
}),
);
});
const DASHBOARD = createMockDashboard({ id: dashboardId, dashcards });
const state: Partial<State> = {
dashboard: createMockDashboardState({
dashboardId: DASHBOARD.id,
dashboards: {
[DASHBOARD.id]: createMockStoreDashboard({
...DASHBOARD,
dashcards: dashcards.map((dc) => dc.id),
}),
},
dashcards: Object.fromEntries(dashcards.map((dc) => [dc.id, dc])),
}),
entities: createMockEntitiesState({ databases: [database] }),
settings: createMockSettingsState(),
};
const getState = () => state;
const dispatch = createMockDispatch(getState);
return { dispatch, getState, getMaxConcurrent: () => maxConcurrent };
}
describe("fetchDashboardCardData", () => {
it("should make at most 5 api calls simultaneously", async () => {
const { dispatch, getState, getMaxConcurrent } = setupConcurrencyTest(
100,
8,
);
await fetchDashboardCardData()(dispatch as never, getState as never);
expect(getMaxConcurrent()).toBe(5);
});
});
describe("reloadDashboardCards", () => {
it("should make at most 5 api calls simultaneously", async () => {
const { dispatch, getState, getMaxConcurrent } = setupConcurrencyTest(
200,
8,
);
await reloadDashboardCards()(dispatch as never, getState as never);
expect(getMaxConcurrent()).toBe(5);
});
});

View File

@@ -5,7 +5,8 @@
.visualization {
background-color: var(--mb-color-background-primary);
border: 1px solid var(--mb-color-border);
border-radius: var(--default-border-radius);
border-top-left-radius: var(--default-border-radius);
border-bottom-left-radius: var(--default-border-radius);
overflow: hidden;
flex: 1;
}

View File

@@ -6,6 +6,7 @@ import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import { isTest } from "metabase/env";
import { isWithinIframe } from "metabase/lib/dom";
import { IFRAMED_IN_SELF } from "metabase/lib/iframe";
import { getTraceparentHeader } from "metabase/lib/otel";
import { delay } from "metabase/lib/promise";
import { PLUGIN_API, PLUGIN_EMBEDDING_SDK } from "metabase/plugins";
@@ -103,6 +104,11 @@ export class Api extends EventEmitter {
DEFAULT_OPTIONS.headers["X-Metabase-Locale"];
}
const traceparent = getTraceparentHeader();
if (traceparent) {
headers["traceparent"] = traceparent;
}
return headers;
}

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