Compare commits
75 Commits
v2-theme-o
...
python_run
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a43b2d35f | ||
|
|
832f8ac44d | ||
|
|
88bdc6c06c | ||
|
|
bdfc24938c | ||
|
|
123f2dafd2 | ||
|
|
16504fd3c8 | ||
|
|
997b8d898e | ||
|
|
ceb518ab4b | ||
|
|
0ed6c4deed | ||
|
|
6c09fca355 | ||
|
|
049526c61c | ||
|
|
a799178420 | ||
|
|
f7b82d5c54 | ||
|
|
a51d1179d3 | ||
|
|
cbed2e45a8 | ||
|
|
418d7a0317 | ||
|
|
3bce633480 | ||
|
|
48327532c5 | ||
|
|
26f71cfeb5 | ||
|
|
3d4e44b776 | ||
|
|
86f3e524ba | ||
|
|
056d61238e | ||
|
|
926610da14 | ||
|
|
75251f933a | ||
|
|
d959d54aec | ||
|
|
0933b7378f | ||
|
|
e3b200b319 | ||
|
|
fb9c0e4499 | ||
|
|
40522b6cba | ||
|
|
b6edd15de2 | ||
|
|
16042f45c7 | ||
|
|
b28ab0ec1d | ||
|
|
add8906504 | ||
|
|
3345365546 | ||
|
|
862dc50c64 | ||
|
|
0941091421 | ||
|
|
0bd69823a4 | ||
|
|
6a01533ca7 | ||
|
|
7d18a1b5a6 | ||
|
|
0bf7e41984 | ||
|
|
95a25e4a3e | ||
|
|
a2c4eef9de | ||
|
|
2f81937643 | ||
|
|
48bf58b162 | ||
|
|
761a7cf01e | ||
|
|
8a76f71003 | ||
|
|
fd90d13ae3 | ||
|
|
a62aab3f1b | ||
|
|
bafd49ee17 | ||
|
|
1f0b461d6f | ||
|
|
1712274b85 | ||
|
|
ea203102e6 | ||
|
|
3c72591b17 | ||
|
|
9a19a5e4f0 | ||
|
|
f387d4aca4 | ||
|
|
1bb6f6a74a | ||
|
|
2ad4292465 | ||
|
|
37c3758748 | ||
|
|
ae9cad5c58 | ||
|
|
bd14e14974 | ||
|
|
85319217cd | ||
|
|
240acb4782 | ||
|
|
e6bc40c223 | ||
|
|
76fcb4d573 | ||
|
|
a92317dd27 | ||
|
|
087a317f70 | ||
|
|
c63fd746d8 | ||
|
|
f29a446aab | ||
|
|
d26bf4e454 | ||
|
|
7aa18ae448 | ||
|
|
97da3d7c7b | ||
|
|
10e8a68cf5 | ||
|
|
209a34b102 | ||
|
|
a6df84c136 | ||
|
|
adb9a5cbf4 |
@@ -29,7 +29,7 @@ This guide covers Clojure and ClojureScript coding conventions for Metabase. See
|
||||
|
||||
**Comments:**
|
||||
|
||||
- `TODO` format: `;; TODO (Name M/D/YY) -- description`
|
||||
- `TODO` format: `;; TODO (Name YYYY-MM-DD) -- description`
|
||||
|
||||
## Code Organization
|
||||
|
||||
|
||||
504
.claude/skills/analytics-events/SKILL.md
Normal file
504
.claude/skills/analytics-events/SKILL.md
Normal file
@@ -0,0 +1,504 @@
|
||||
---
|
||||
name: analytics-events
|
||||
description: Add product analytics events to track user interactions in the Metabase frontend
|
||||
allowed-tools: Read, Write, Edit, Grep, Glob
|
||||
---
|
||||
|
||||
# Frontend Analytics Events Skill
|
||||
|
||||
This skill helps you add product analytics (Snowplow) events to track user interactions in the Metabase frontend codebase.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Analytics events in Metabase use Snowplow with typed event schemas. All events must be defined in TypeScript types before use.
|
||||
|
||||
**Key Files:**
|
||||
- `frontend/src/metabase-types/analytics/event.ts` - Event type definitions
|
||||
- `frontend/src/metabase-types/analytics/schema.ts` - Schema registry
|
||||
- `frontend/src/metabase/lib/analytics.ts` - Core tracking functions
|
||||
- Feature-specific `analytics.ts` files - Tracking function wrappers
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
When adding a new analytics event:
|
||||
|
||||
- [ ] Define event type in `frontend/src/metabase-types/analytics/event.ts`
|
||||
- [ ] Add event to appropriate union type (e.g., `DataStudioEvent`, `SimpleEvent`)
|
||||
- [ ] Create tracking function in feature's `analytics.ts` file
|
||||
- [ ] Import and call tracking function at the interaction point
|
||||
- [ ] Use `trackSimpleEvent()` for basic events (most common)
|
||||
|
||||
## Event Schema Types
|
||||
|
||||
### 1. Simple Events (Most Common)
|
||||
|
||||
Use `SimpleEventSchema` for straightforward tracking. It supports these standard fields:
|
||||
|
||||
```typescript
|
||||
type SimpleEventSchema = {
|
||||
event: string; // Required: Event name (snake_case)
|
||||
target_id?: number | null; // Optional: ID of affected entity
|
||||
triggered_from?: string | null; // Optional: UI location/context
|
||||
duration_ms?: number | null; // Optional: Duration in milliseconds
|
||||
result?: string | null; // Optional: Outcome (e.g., "success", "failure")
|
||||
event_detail?: string | null; // Optional: Additional detail/variant
|
||||
};
|
||||
```
|
||||
|
||||
**When to use:** 90% of events fit this schema. Use for clicks, opens, closes, creates, deletes, etc.
|
||||
|
||||
### 2. Custom Schemas (legacy, no events are being added)
|
||||
|
||||
Consider adding new event schema only in very special cases.
|
||||
|
||||
**Examples:** `DashboardEventSchema`, `CleanupEventSchema`, `QuestionEventSchema`
|
||||
|
||||
## Step-by-Step: Adding a Simple Event
|
||||
|
||||
### Example: Track when a user applies filters in a table picker
|
||||
|
||||
#### Step 1: Define Event Types
|
||||
|
||||
Add event type definitions to `frontend/src/metabase-types/analytics/event.ts`:
|
||||
|
||||
```typescript
|
||||
export type DataStudioTablePickerFiltersAppliedEvent = ValidateEvent<{
|
||||
event: "data_studio_table_picker_filters_applied";
|
||||
}>;
|
||||
|
||||
export type DataStudioTablePickerFiltersClearedEvent = ValidateEvent<{
|
||||
event: "data_studio_table_picker_filters_cleared";
|
||||
}>;
|
||||
```
|
||||
|
||||
#### Step 2: Add to Union Type
|
||||
|
||||
Find or create the appropriate union type and add your events:
|
||||
|
||||
```typescript
|
||||
export type DataStudioEvent =
|
||||
| DataStudioLibraryCreatedEvent
|
||||
| DataStudioTablePublishedEvent
|
||||
| DataStudioGlossaryCreatedEvent
|
||||
| DataStudioGlossaryEditedEvent
|
||||
| DataStudioGlossaryDeletedEvent
|
||||
| DataStudioTablePickerFiltersAppliedEvent // <- Add here
|
||||
| DataStudioTablePickerFiltersClearedEvent; // <- Add here
|
||||
```
|
||||
|
||||
#### Step 3: Create Tracking Functions
|
||||
|
||||
In your feature's `analytics.ts` file (e.g., `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`):
|
||||
|
||||
```typescript
|
||||
import { trackSimpleEvent } from "metabase/lib/analytics";
|
||||
|
||||
export const trackDataStudioTablePickerFiltersApplied = () => {
|
||||
trackSimpleEvent({
|
||||
event: "data_studio_table_picker_filters_applied",
|
||||
});
|
||||
};
|
||||
|
||||
export const trackDataStudioTablePickerFiltersCleared = () => {
|
||||
trackSimpleEvent({
|
||||
event: "data_studio_table_picker_filters_cleared",
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
#### Step 4: Use in Components
|
||||
|
||||
Import and call the tracking function at the interaction point:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
trackDataStudioTablePickerFiltersApplied,
|
||||
trackDataStudioTablePickerFiltersCleared,
|
||||
} from "metabase-enterprise/data-studio/analytics";
|
||||
|
||||
function FilterPopover({ filters, onSubmit }) {
|
||||
const handleReset = () => {
|
||||
trackDataStudioTablePickerFiltersCleared(); // <- Track here
|
||||
onSubmit(emptyFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
trackDataStudioTablePickerFiltersApplied(); // <- Track here
|
||||
onSubmit(form);
|
||||
}}
|
||||
>
|
||||
{/* form content */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using SimpleEventSchema Fields
|
||||
|
||||
### Example: Event with target_id
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type DataStudioLibraryCreatedEvent = ValidateEvent<{
|
||||
event: "data_studio_library_created";
|
||||
target_id: number | null;
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackDataStudioLibraryCreated = (id: CollectionId) => {
|
||||
trackSimpleEvent({
|
||||
event: "data_studio_library_created",
|
||||
target_id: Number(id),
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
trackDataStudioLibraryCreated(newLibrary.id);
|
||||
```
|
||||
|
||||
### Example: Event with triggered_from
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type NewButtonClickedEvent = ValidateEvent<{
|
||||
event: "new_button_clicked";
|
||||
triggered_from: "app-bar" | "empty-collection";
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackNewButtonClicked = (location: "app-bar" | "empty-collection") => {
|
||||
trackSimpleEvent({
|
||||
event: "new_button_clicked",
|
||||
triggered_from: location,
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
<Button onClick={() => {
|
||||
trackNewButtonClicked("app-bar");
|
||||
handleCreate();
|
||||
}}>
|
||||
New
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Example: Event with event_detail
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type MetadataEditEvent = ValidateEvent<{
|
||||
event: "metadata_edited";
|
||||
event_detail: "type_casting" | "semantic_type_change" | "visibility_change";
|
||||
triggered_from: "admin" | "data_studio";
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackMetadataChange = (
|
||||
detail: "type_casting" | "semantic_type_change" | "visibility_change",
|
||||
location: "admin" | "data_studio"
|
||||
) => {
|
||||
trackSimpleEvent({
|
||||
event: "metadata_edited",
|
||||
event_detail: detail,
|
||||
triggered_from: location,
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
trackMetadataChange("semantic_type_change", "data_studio");
|
||||
```
|
||||
|
||||
### Example: Event with result and duration
|
||||
|
||||
```typescript
|
||||
// Type definition
|
||||
export type MoveToTrashEvent = ValidateEvent<{
|
||||
event: "moved-to-trash";
|
||||
target_id: number | null;
|
||||
triggered_from: "collection" | "detail_page" | "cleanup_modal";
|
||||
duration_ms: number | null;
|
||||
result: "success" | "failure";
|
||||
event_detail: "question" | "model" | "metric" | "dashboard";
|
||||
}>;
|
||||
|
||||
// Tracking function
|
||||
export const trackMoveToTrash = (params: {
|
||||
targetId: number | null;
|
||||
triggeredFrom: "collection" | "detail_page" | "cleanup_modal";
|
||||
durationMs: number | null;
|
||||
result: "success" | "failure";
|
||||
itemType: "question" | "model" | "metric" | "dashboard";
|
||||
}) => {
|
||||
trackSimpleEvent({
|
||||
event: "moved-to-trash",
|
||||
target_id: params.targetId,
|
||||
triggered_from: params.triggeredFrom,
|
||||
duration_ms: params.durationMs,
|
||||
result: params.result,
|
||||
event_detail: params.itemType,
|
||||
});
|
||||
};
|
||||
|
||||
// Usage with timing
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
await moveToTrash(item);
|
||||
trackMoveToTrash({
|
||||
targetId: item.id,
|
||||
triggeredFrom: "collection",
|
||||
durationMs: Date.now() - startTime,
|
||||
result: "success",
|
||||
itemType: "question",
|
||||
});
|
||||
} catch (error) {
|
||||
trackMoveToTrash({
|
||||
targetId: item.id,
|
||||
triggeredFrom: "collection",
|
||||
durationMs: Date.now() - startTime,
|
||||
result: "failure",
|
||||
itemType: "question",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Event Names (snake_case)
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
"data_studio_library_created"
|
||||
"table_picker_filters_applied"
|
||||
"metabot_chat_opened"
|
||||
|
||||
// Bad
|
||||
"DataStudioLibraryCreated" // Wrong case
|
||||
"tablePickerFiltersApplied" // Wrong case
|
||||
"filters-applied" // Use underscore, not hyphen
|
||||
```
|
||||
|
||||
### Event Type Names (PascalCase with "Event" suffix)
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
DataStudioLibraryCreatedEvent
|
||||
TablePickerFiltersAppliedEvent
|
||||
MetabotChatOpenedEvent
|
||||
|
||||
// Bad
|
||||
dataStudioLibraryCreated // Wrong case
|
||||
DataStudioLibraryCreated // Missing "Event" suffix
|
||||
```
|
||||
|
||||
### Tracking Function Names (camelCase with "track" prefix)
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
trackDataStudioLibraryCreated
|
||||
trackTablePickerFiltersApplied
|
||||
trackMetabotChatOpened
|
||||
|
||||
// Bad
|
||||
DataStudioLibraryCreated // Missing "track" prefix
|
||||
track_library_created // Wrong case
|
||||
logLibraryCreated // Use "track" prefix
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Feature-Specific Union Types
|
||||
|
||||
Group related events together:
|
||||
|
||||
```typescript
|
||||
export type DataStudioEvent =
|
||||
| DataStudioLibraryCreatedEvent
|
||||
| DataStudioTablePublishedEvent
|
||||
| DataStudioGlossaryCreatedEvent;
|
||||
|
||||
export type MetabotEvent =
|
||||
| MetabotChatOpenedEvent
|
||||
| MetabotRequestSentEvent
|
||||
| MetabotFixQueryClickedEvent;
|
||||
|
||||
// Then add to SimpleEvent union
|
||||
export type SimpleEvent =
|
||||
| /* other events */
|
||||
| DataStudioEvent
|
||||
| MetabotEvent
|
||||
| /* more events */;
|
||||
```
|
||||
|
||||
### Pattern 2: Conditional Tracking
|
||||
|
||||
Track different events based on user action:
|
||||
|
||||
```typescript
|
||||
const handleSave = async () => {
|
||||
if (isNewItem) {
|
||||
await createItem(data);
|
||||
trackItemCreated(newItem.id);
|
||||
} else {
|
||||
await updateItem(id, data);
|
||||
trackItemUpdated(id);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Don't: Add custom fields to SimpleEvent
|
||||
|
||||
```typescript
|
||||
// WRONG - SimpleEvent doesn't support custom fields
|
||||
export const trackFiltersApplied = (filters: FilterState) => {
|
||||
trackSimpleEvent({
|
||||
event: "filters_applied",
|
||||
data_layer: filters.dataLayer, // ❌ Not in SimpleEventSchema
|
||||
data_source: filters.dataSource, // ❌ Not in SimpleEventSchema
|
||||
with_owner: filters.hasOwner, // ❌ Not in SimpleEventSchema
|
||||
});
|
||||
};
|
||||
|
||||
// RIGHT - Use only standard SimpleEventSchema fields
|
||||
export const trackFiltersApplied = () => {
|
||||
trackSimpleEvent({
|
||||
event: "filters_applied",
|
||||
});
|
||||
};
|
||||
|
||||
// Or use event_detail for a single variant
|
||||
export const trackFilterApplied = (filterType: string) => {
|
||||
trackSimpleEvent({
|
||||
event: "filter_applied",
|
||||
event_detail: filterType, // ✓ "data_layer", "data_source", etc.
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Don't: Forget to add event to union type
|
||||
|
||||
```typescript
|
||||
// Define the event
|
||||
export type NewFeatureClickedEvent = ValidateEvent<{
|
||||
event: "new_feature_clicked";
|
||||
}>;
|
||||
|
||||
// ❌ WRONG - Forgot to add to SimpleEvent union
|
||||
// Event won't be recognized by TypeScript
|
||||
|
||||
// ✓ RIGHT - Add to appropriate union
|
||||
export type SimpleEvent =
|
||||
| /* other events */
|
||||
| NewFeatureClickedEvent;
|
||||
```
|
||||
|
||||
### Don't: Mix up event name formats
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
event: "dataStudioLibraryCreated" // camelCase
|
||||
event: "data-studio-library-created" // kebab-case
|
||||
event: "Data_Studio_Library_Created" // Mixed case
|
||||
|
||||
// RIGHT
|
||||
event: "data_studio_library_created" // snake_case
|
||||
```
|
||||
|
||||
### Don't: Track PII or sensitive data
|
||||
|
||||
```typescript
|
||||
// WRONG - Don't track user emails, names, or sensitive data
|
||||
trackSimpleEvent({
|
||||
event: "user_logged_in",
|
||||
event_detail: user.email, // ❌ PII
|
||||
});
|
||||
|
||||
// RIGHT - Track non-sensitive identifiers only
|
||||
trackSimpleEvent({
|
||||
event: "user_logged_in",
|
||||
target_id: user.id, // ✓ Just the ID
|
||||
});
|
||||
```
|
||||
|
||||
### Don't: Forget to track both success and failure
|
||||
|
||||
```typescript
|
||||
// WRONG - Only tracking success
|
||||
try {
|
||||
await saveData();
|
||||
trackDataSaved();
|
||||
} catch (error) {
|
||||
// ❌ No tracking for failure case
|
||||
}
|
||||
|
||||
// RIGHT - Track both outcomes
|
||||
try {
|
||||
await saveData();
|
||||
trackDataSaved({ result: "success" });
|
||||
} catch (error) {
|
||||
trackDataSaved({ result: "failure" });
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Analytics Events
|
||||
|
||||
While developing, you can verify events are firing:
|
||||
|
||||
1. **Check browser console** - When `SNOWPLOW_ENABLED=true` in dev, events are logged
|
||||
2. **Use shouldLogAnalytics** - Set in `metabase/env` to see all analytics in console
|
||||
3. **Check Snowplow debugger** - Browser extension for Snowplow events
|
||||
|
||||
Example console output:
|
||||
|
||||
```
|
||||
[SNOWPLOW EVENT | event sent:true], data_studio_table_picker_filters_applied
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
### Where to put tracking functions:
|
||||
|
||||
```
|
||||
Feature-specific analytics functions:
|
||||
frontend/src/metabase/{feature}/analytics.ts
|
||||
enterprise/frontend/src/metabase-enterprise/{feature}/analytics.ts
|
||||
|
||||
Event type definitions (all in one place):
|
||||
frontend/src/metabase-types/analytics/event.ts
|
||||
|
||||
Core tracking utilities:
|
||||
frontend/src/metabase/lib/analytics.ts
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
See these files for reference:
|
||||
|
||||
- **Simple events**: `enterprise/frontend/src/metabase-enterprise/data-studio/analytics.ts`
|
||||
- **Events with variants**: `frontend/src/metabase/dashboard/analytics.ts`
|
||||
- **Complex events**: `frontend/src/metabase/query_builder/analytics.js`
|
||||
- **Event type examples**: `frontend/src/metabase-types/analytics/event.ts`
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
1. **Identify the user interaction** to track
|
||||
2. **Decide on event name** (snake_case, descriptive)
|
||||
3. **Define event type** in `event.ts` using `ValidateEvent`
|
||||
4. **Add to union type** (create feature union if needed)
|
||||
5. **Create tracking function** in feature's `analytics.ts`
|
||||
6. **Import and call** at the interaction point
|
||||
7. **Test** that events fire correctly
|
||||
|
||||
## Tips
|
||||
|
||||
- **Be specific** - `filters_applied` is better than `action_performed`
|
||||
- **Use past tense** - `library_created` not `create_library`
|
||||
- **Group related events** - Create feature-specific event union types
|
||||
- **Track meaningful actions** - Not every click needs tracking
|
||||
- **Consider the data** - What would you want to analyze later?
|
||||
- **Stay consistent** - Follow existing naming patterns in the codebase
|
||||
- **Document context** - Use `triggered_from` to track where the action happened
|
||||
@@ -54,7 +54,7 @@ Use this to scan through changes efficiently:
|
||||
- [ ] Public vars in `src` or `enterprise/backend/src` have useful docstrings
|
||||
- [ ] Docstrings use Markdown conventions
|
||||
- [ ] References use `[[other-var]]` not backticks
|
||||
- [ ] `TODO` comments include author and date: `;; TODO (Name 1/1/25) -- description`
|
||||
- [ ] `TODO` comments include author and date: `;; TODO (Name 2025-01-01) -- description`
|
||||
|
||||
### Code Organization
|
||||
|
||||
@@ -118,7 +118,7 @@ Quick scan for common issues:
|
||||
| `update-db`, `save-model` | Missing `!` for side effects: `update-db!`, `save-model!` |
|
||||
| `snake_case_var` | Should use kebab-case |
|
||||
| Public var without docstring | Add docstring explaining purpose |
|
||||
| `;; TODO fix this` | Missing author/date: `;; TODO (Name 1/1/25) -- description` |
|
||||
| `;; TODO fix this` | Missing author/date: `;; TODO (Name 2025-01-01) -- description` |
|
||||
| `(defn foo ...)` in namespace used elsewhere | Should be `(defn ^:private foo ...)` |
|
||||
| Function > 20 lines | Consider breaking up into smaller functions |
|
||||
| `/api/dashboards/:id` | Use singular: `/api/dashboard/:id` |
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
:metabase/missing-test-expr-requires-in-cljs {:level :warning}
|
||||
:metabase/modules {:level :warning} ; more config in `.clj-kondo/config/modules/config.edn`
|
||||
:metabase/namespace-name {:level :warning}
|
||||
:metabase/no-jsqlparser-imports {:level :warning}
|
||||
:metabase/require-shape-checker {:level :warning}
|
||||
:metabase/test-helpers-use-non-thread-safe-functions {:level :warning} ; more config in `.clj-kondo/config/test-helpers/config.edn`
|
||||
:metabase/tests-must-live-in-known-modules {:level :warning}
|
||||
@@ -299,7 +300,8 @@
|
||||
metabase-enterprise.transforms.interface/execute! {:message "Use metabase-enterprise.transforms.execute/execute! instead of metabase-enterprise.transforms.interface/execute!"}
|
||||
next.jdbc/active-tx? {:message "Use metabase.app-db.core/in-transaction? to see if we are in a transaction"}
|
||||
toucan2.core/debug {:message "Don't commit code using t2/debug"}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}}
|
||||
toucan2.tools.debug/debug {:message "Don't commit code using t2/debug"}
|
||||
macaw.core/parsed-query {:message "Use metabase.driver.util/parsed-query instead of macaw.core/parsed-query"}}
|
||||
|
||||
:discouraged-namespace
|
||||
{camel-snake-kebab.core {:message "CSK is not Turkish-safe, use the versions in metabase.util instead."}
|
||||
@@ -522,8 +524,10 @@
|
||||
metabase-enterprise.advanced-permissions.models.permissions.application-permissions-test/with-new-group-and-current-graph hooks.common/with-two-top-level-bindings
|
||||
metabase-enterprise.audit-app.pages-test/with-temp-objects hooks.common/with-one-binding
|
||||
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
|
||||
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
|
||||
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
|
||||
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
|
||||
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
|
||||
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp
|
||||
metabase-enterprise.transforms-python.drivers-test/with-test-table hooks.common/with-two-bindings
|
||||
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding
|
||||
|
||||
@@ -383,7 +383,6 @@
|
||||
:uses #{api
|
||||
app-db
|
||||
collections
|
||||
config
|
||||
eid-translation
|
||||
events
|
||||
lib-be
|
||||
@@ -809,6 +808,7 @@
|
||||
lib-be
|
||||
models
|
||||
permissions
|
||||
remote-sync
|
||||
search
|
||||
util}}
|
||||
|
||||
@@ -1456,6 +1456,7 @@
|
||||
models
|
||||
permissions
|
||||
premium-features
|
||||
query-processor
|
||||
request
|
||||
task
|
||||
util}}
|
||||
@@ -1499,7 +1500,6 @@
|
||||
:uses #{analytics
|
||||
api
|
||||
collections
|
||||
config
|
||||
events
|
||||
models
|
||||
util}}
|
||||
@@ -1649,7 +1649,6 @@
|
||||
:uses #{analytics
|
||||
api
|
||||
app-db
|
||||
config
|
||||
database-routing
|
||||
driver
|
||||
events
|
||||
@@ -2332,6 +2331,7 @@
|
||||
premium-features
|
||||
queries
|
||||
query-processor
|
||||
remote-sync
|
||||
request
|
||||
revisions
|
||||
search
|
||||
|
||||
@@ -99,12 +99,48 @@
|
||||
:message "Metabase namespaces should have the form metabase[-enterprise].<module>.* [:metabase/namespace-name]"
|
||||
:type :metabase/namespace-name)))))))
|
||||
|
||||
(defn- ns-form-node->import-node [ns-form-node]
|
||||
(some (fn [node]
|
||||
(when (and (hooks/list-node? node)
|
||||
(let [first-child (first (:children node))]
|
||||
(and (hooks/keyword-node? first-child)
|
||||
(= (hooks/sexpr first-child) :import))))
|
||||
node))
|
||||
(:children ns-form-node)))
|
||||
|
||||
(defn- jsqlparser-import?
|
||||
"Check if a symbol or string represents a jsqlparser import."
|
||||
[s]
|
||||
(str/starts-with? (str s) "net.sf.jsqlparser"))
|
||||
|
||||
(defn- lint-jsqlparser-imports [ns-form-node]
|
||||
(when-let [import-node (ns-form-node->import-node ns-form-node)]
|
||||
(doseq [node (rest (:children import-node))
|
||||
:when (not (contains? (hooks.common/ignored-linters node) :metabase/no-jsqlparser-imports))]
|
||||
(cond
|
||||
;; Handle (net.sf.jsqlparser.foo Bar Baz) form
|
||||
(hooks/list-node? node)
|
||||
(let [package-node (first (:children node))
|
||||
package-name (when package-node (str (hooks/sexpr package-node)))]
|
||||
(when (jsqlparser-import? package-name)
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
|
||||
:type :metabase/no-jsqlparser-imports))))
|
||||
|
||||
;; Handle net.sf.jsqlparser.foo.Bar form (single class import)
|
||||
(hooks/token-node? node)
|
||||
(when (jsqlparser-import? (hooks/sexpr node))
|
||||
(hooks/reg-finding! (assoc (meta node)
|
||||
:message "Don't import net.sf.jsqlparser classes directly. This code should be agnostic to the parsing library. [:metabase/no-jsqlparser-imports]"
|
||||
:type :metabase/no-jsqlparser-imports)))))))
|
||||
|
||||
(defn lint-ns [x]
|
||||
(doto (:node x)
|
||||
lint-require-shapes
|
||||
lint-requires-on-new-lines
|
||||
(lint-modules (modules/config x))
|
||||
lint-namespace-name)
|
||||
lint-namespace-name
|
||||
lint-jsqlparser-imports)
|
||||
x)
|
||||
|
||||
(comment
|
||||
|
||||
40
.github/scripts/find-linear-issue.js
vendored
Normal file
40
.github/scripts/find-linear-issue.js
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
async function findLinearIssue({ githubIssueUrl, linearApiKey }) {
|
||||
const response = await fetch("https://api.linear.app/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: linearApiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query {
|
||||
attachmentsForURL(url: "${githubIssueUrl}") {
|
||||
nodes {
|
||||
issue { identifier }
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, message: `Linear API error: ${response.status}` };
|
||||
}
|
||||
|
||||
if (!data.data?.attachmentsForURL?.nodes?.length) {
|
||||
return {
|
||||
success: false,
|
||||
message: `No Linear issue linked to ${githubIssueUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
issue: data.data.attachmentsForURL.nodes[0].issue.identifier,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { findLinearIssue };
|
||||
3
.github/scripts/setup-python-runner.sh
vendored
3
.github/scripts/setup-python-runner.sh
vendored
@@ -19,11 +19,12 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
echo "Cloning python-runner repository..."
|
||||
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
git clone https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
|
||||
if [ "$SKIP_START" = false ]; then
|
||||
echo "Starting python-runner..."
|
||||
cd python-runner
|
||||
git checkout origin/memory_improvements_2
|
||||
make run-ci 1> ../python-runner.out.log 2> ../python-runner.err.log
|
||||
make logs 1>> ../python-runner.out.log 2>> ../python-runner.err.log &
|
||||
cd ..
|
||||
|
||||
30
.github/workflows/claude-code-bug-fix.yml
vendored
30
.github/workflows/claude-code-bug-fix.yml
vendored
@@ -70,6 +70,31 @@ jobs:
|
||||
core.setFailed(error.message);
|
||||
}
|
||||
|
||||
- name: Find linked Linear issue
|
||||
id: find_linear
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
ISSUE_NUMBER: ${{ steps.get_issue.outputs.issue_number }}
|
||||
with:
|
||||
script: |
|
||||
const { findLinearIssue } = require('${{ github.workspace }}/.github/scripts/find-linear-issue.js');
|
||||
|
||||
const githubIssueUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${process.env.ISSUE_NUMBER}`;
|
||||
|
||||
const result = await findLinearIssue({
|
||||
githubIssueUrl,
|
||||
linearApiKey: process.env.LINEAR_API_KEY
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`Found Linear issue: ${result.issue}`);
|
||||
core.setOutput('linear_issue', result.issue);
|
||||
} else {
|
||||
console.log(`Could not find Linear issue: ${result.message}`);
|
||||
}
|
||||
|
||||
- name: Set up git configuration
|
||||
run: |
|
||||
git config --global user.name "Claude Code[bot]"
|
||||
@@ -259,6 +284,7 @@ jobs:
|
||||
core.setOutput('reviewers', issueCreator);
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create_pr
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
@@ -279,6 +305,8 @@ jobs:
|
||||
body: |
|
||||
This PR automatically fixes the bug reported in #${{ env.ISSUE_NUMBER }}.
|
||||
|
||||
${{ steps.find_linear.outputs.linear_issue && format('Closes {0}', steps.find_linear.outputs.linear_issue) || '' }}
|
||||
|
||||
## Bug Report
|
||||
**Original Issue:** #${{ env.ISSUE_NUMBER }}
|
||||
**Reporter:** @${{ env.ISSUE_AUTHOR }}
|
||||
@@ -299,7 +327,6 @@ jobs:
|
||||
- If issues are found, either fix them or close this PR
|
||||
|
||||
---
|
||||
Closes #${{ env.ISSUE_NUMBER }}
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
@@ -309,6 +336,7 @@ jobs:
|
||||
automated-fix
|
||||
ci skip
|
||||
reviewers: ${{ steps.prepare_reviewers.outputs.reviewers }}
|
||||
assignees: ${{ github.actor }}
|
||||
|
||||
- name: Log existing branch update
|
||||
if: steps.check_changes.outputs.has_changes == 'true' && env.BRANCH_EXISTS == 'true'
|
||||
|
||||
33
.github/workflows/clean-cache.yml
vendored
33
.github/workflows/clean-cache.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: Cleanup caches by a branch
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
gh extension install actions/gh-actions-cache
|
||||
|
||||
REPO=${{ github.repository }}
|
||||
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
|
||||
|
||||
echo "Fetching list of cache key"
|
||||
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
|
||||
|
||||
## Setting this to not fail the workflow while deleting cache keys.
|
||||
set +e
|
||||
echo "Deleting caches..."
|
||||
for cacheKey in $cacheKeysForPR
|
||||
do
|
||||
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
|
||||
done
|
||||
echo "Done"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
25
.github/workflows/drivers.yml
vendored
25
.github/workflows/drivers.yml
vendored
@@ -85,7 +85,6 @@ jobs:
|
||||
|
||||
be-tests-athena-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.athena-should-run == 'true' || needs.determine-driver-skips.outputs.athena-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -135,7 +134,6 @@ jobs:
|
||||
|
||||
be-tests-bigquery-cloud-sdk-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.bigquery-should-run == 'true' || needs.determine-driver-skips.outputs.bigquery-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -146,6 +144,7 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
# DS 2025-09-26 temporarily disabled while we diagnose flakes
|
||||
# - name: Transforms Python Tests
|
||||
# test-args: >-
|
||||
@@ -206,7 +205,6 @@ jobs:
|
||||
|
||||
be-tests-clickhouse-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.clickhouse-should-run == 'true' || needs.determine-driver-skips.outputs.clickhouse-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -288,7 +286,6 @@ jobs:
|
||||
|
||||
be-tests-druid-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.druid-should-run == 'true' || needs.determine-driver-skips.outputs.druid-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -336,7 +333,6 @@ jobs:
|
||||
|
||||
be-tests-druid-jdbc-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.druid-jdbc-should-run == 'true' || needs.determine-driver-skips.outputs.druid-jdbc-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -384,7 +380,6 @@ jobs:
|
||||
|
||||
be-tests-databricks-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -432,7 +427,6 @@ jobs:
|
||||
|
||||
be-tests-databricks-multi-catalog-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -481,7 +475,6 @@ jobs:
|
||||
|
||||
be-tests-h2:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.h2-should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -511,7 +504,6 @@ jobs:
|
||||
|
||||
be-tests-mysql-mariadb:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.mysql-mariadb-should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -647,7 +639,6 @@ jobs:
|
||||
|
||||
be-tests-mongo:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.mongo-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -727,7 +718,6 @@ jobs:
|
||||
|
||||
be-tests-mongo-ssl:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.mongo-ssl-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-ssl-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -803,7 +793,7 @@ jobs:
|
||||
|
||||
be-tests-mongo-sharded-cluster-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-should-run == 'true' || needs.determine-driver-skips.outputs.mongo-sharded-cluster-quarantine-conflict == 'true' }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -848,7 +838,6 @@ jobs:
|
||||
|
||||
be-tests-oracle:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.oracle-should-run == 'true' || needs.determine-driver-skips.outputs.oracle-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -933,7 +922,6 @@ jobs:
|
||||
|
||||
be-tests-postgres:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.postgres-should-run == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -1042,7 +1030,6 @@ jobs:
|
||||
|
||||
be-tests-presto-jdbc-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.presto-jdbc-should-run == 'true' || needs.determine-driver-skips.outputs.presto-jdbc-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -1123,7 +1110,6 @@ jobs:
|
||||
|
||||
be-tests-redshift-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.redshift-should-run == 'true' || needs.determine-driver-skips.outputs.redshift-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -1213,7 +1199,6 @@ jobs:
|
||||
|
||||
be-tests-snowflake-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.snowflake-should-run == 'true' || needs.determine-driver-skips.outputs.snowflake-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
@@ -1224,6 +1209,7 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
:fail-fast? true
|
||||
|
||||
# DS 2025-09-26 temporarily disabled while we diagnose flakes
|
||||
# - name: Transforms Python Tests
|
||||
@@ -1294,7 +1280,6 @@ jobs:
|
||||
|
||||
be-tests-sparksql-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.sparksql-should-run == 'true' || needs.determine-driver-skips.outputs.sparksql-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -1340,7 +1325,6 @@ jobs:
|
||||
|
||||
be-tests-sqlite-ee:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.sqlite-should-run == 'true' || needs.determine-driver-skips.outputs.sqlite-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -1381,7 +1365,6 @@ jobs:
|
||||
|
||||
be-tests-sqlserver:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.sqlserver-should-run == 'true' || needs.determine-driver-skips.outputs.sqlserver-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
@@ -1502,7 +1485,6 @@ jobs:
|
||||
|
||||
login-to-ecr:
|
||||
needs: determine-driver-skips
|
||||
if: ${{ needs.determine-driver-skips.outputs.vertica-should-run == 'true' || needs.determine-driver-skips.outputs.vertica-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
@@ -1532,7 +1514,6 @@ jobs:
|
||||
|
||||
be-tests-vertica-ee:
|
||||
needs: [determine-driver-skips, login-to-ecr]
|
||||
if: ${{ needs.determine-driver-skips.outputs.vertica-should-run == 'true' || needs.determine-driver-skips.outputs.vertica-quarantine-conflict == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
|
||||
@@ -120,6 +120,10 @@ jobs:
|
||||
- name: Prepare Cypress environment
|
||||
id: cypress-prep
|
||||
uses: ./.github/actions/prepare-cypress
|
||||
|
||||
- name: Compile CLJS
|
||||
run: yarn build-pure:cljs
|
||||
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
|
||||
3
.github/workflows/e2e-test.yml
vendored
3
.github/workflows/e2e-test.yml
vendored
@@ -104,6 +104,9 @@ jobs:
|
||||
if: github.run_attempt != '1'
|
||||
run: echo "specs=$(cat failed-specs)" >> $GITHUB_ENV
|
||||
|
||||
- name: Compile CLJS
|
||||
run: yarn build-pure:cljs
|
||||
|
||||
- name: Run Metabase
|
||||
run: node e2e/runner/run_cypress_ci.js start
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -19,6 +19,10 @@ For detailed guidance on writing and reviewing code and documentation, see the s
|
||||
- **[docs-write](.claude/skills/docs-write/SKILL.md)** - Documentation writing with Metabase style guide
|
||||
- **[docs-review](.claude/skills/docs-review/SKILL.md)** - Documentation review checklist
|
||||
|
||||
## Frontend
|
||||
|
||||
- **[analytics-events](.claude/skills/analytics-events/SKILL.md)** - Add product analytics events to track user interactions
|
||||
|
||||
## Tool Preferences
|
||||
|
||||
If `clojure-mcp` tools are available, prefer them over shell-based alternatives for Clojure development.
|
||||
|
||||
46
bb.edn
46
bb.edn
@@ -307,32 +307,38 @@
|
||||
:examples [["./bin/mage -driver-decisions --git-ref=master --is-master-or-release=false --pr-labels= --skip=false" "Determine driver decisions for a PR"]
|
||||
["./bin/mage -driver-decisions --pr-labels=ci:all-cloud-drivers" "Run all cloud drivers via label"]
|
||||
["./bin/mage -driver-decisions --github-output-only" "Output only GITHUB_OUTPUT format (for CI)"]]
|
||||
:requires [[clojure.string :as str]
|
||||
[table.core :as table]
|
||||
[mage.modules]]
|
||||
:usage-fn
|
||||
(fn [_]
|
||||
(->>
|
||||
["# How we decide to run a driver's tests. For each driver, we check these conditions in order (first match wins):"
|
||||
" |----------|-------------------------------------|--------|-------------------------------------------|"
|
||||
" | Priority | Condition | Result | Reason |"
|
||||
" |----------|-------------------------------------|--------|-------------------------------------------|"
|
||||
" | 1 | Global skip (no backend changes) | SKIP | workflow skip (no backend changes) |"
|
||||
" | 2 | Driver is :h2 | RUN | H2 always runs |"
|
||||
" | 3 | Driver is quarantined | SKIP | driver is quarantined |"
|
||||
" | 3(a) | ...and has break-quarantine-X label | RUN | anti-quarantine label present |"
|
||||
" | 4 | On master or release branch | RUN | master/release branch |"
|
||||
" | 5 | Cloud driver + ci:all-cloud-drivers | RUN | ci:all-cloud-drivers label |"
|
||||
" | 6 | Cloud driver + its files changed | RUN | driver files changed |"
|
||||
" | 7 | Cloud driver, no relevant changes | SKIP | no relevant changes for cloud driver |"
|
||||
" | 8 | Driver deps affected by changes | RUN | driver module affected by shared code |"
|
||||
" | 9 | Self-hosted driver, not affected | SKIP | driver module not affected |"
|
||||
" |----------|-------------------------------------|--------|-------------------------------------------|"
|
||||
""] (str/join "\n")))
|
||||
(fn [_current-task]
|
||||
(let [driver-triggers (vec (sort mage.modules/default-modules-which-trigger-drivers))]
|
||||
(str
|
||||
"# How we decide to run a driver's tests. For each driver, we check these conditions in order (first match wins):\n"
|
||||
(with-out-str
|
||||
(table/table [["Priority" "Condition" "Result" "Reason"]
|
||||
["1" "Global skip (no backend changes)" "SKIP" "workflow skip (no backend changes)"]
|
||||
["2" "Driver is :h2 or :postgres" "RUN" "H2/Postgres always run"]
|
||||
["3" "Driver is quarantined" "SKIP" "driver is quarantined"]
|
||||
["3(a)" "...and has break-quarantine-X label" "RUN" "anti-quarantine label present"]
|
||||
["4" "On master or release branch" "RUN" "master/release branch"]
|
||||
["5" "Cloud driver + ci:all-cloud-drivers" "RUN" "ci:all-cloud-drivers label"]
|
||||
["6"
|
||||
"Cloud driver + its files changed"
|
||||
"RUN"
|
||||
(str "any of " (pr-str driver-triggers) " affected by cloud-driver module changes")]
|
||||
["7" "Cloud driver, no relevant changes" "SKIP" "no relevant changes for cloud driver"]
|
||||
["8"
|
||||
(str (pr-str driver-triggers) " module deps affected")
|
||||
"RUN"
|
||||
(str "any of " (pr-str driver-triggers) " affected by module changes")]
|
||||
["9" "Self-hosted driver, not affected" "SKIP" "driver module not affected"]])))))
|
||||
:options [[nil "--git-ref REF" "The git ref to compute changed modules against"]
|
||||
[nil "--is-master-or-release MASTER_OR_RELEASE"]
|
||||
[nil "--pr-labels PR_LABELS" "Comma delimited labels on the pr"]
|
||||
[nil "--skip SKIP"]
|
||||
[nil "--github-output-only" "Only output GITHUB_OUTPUT format (no human-readable output)"]]
|
||||
:requires [[clojure.string :as str]
|
||||
[mage.modules]]
|
||||
|
||||
:task (task! (mage.modules/-main parsed))}
|
||||
|
||||
-install-merge-drivers
|
||||
|
||||
6
deps.edn
6
deps.edn
@@ -80,11 +80,11 @@
|
||||
hiccup/hiccup {:mvn/version "2.0.0"} ; HTML templating
|
||||
inflections/inflections {:mvn/version "0.15.0"} ; Clojure/Script library used for prularizing words
|
||||
instaparse/instaparse {:mvn/version "1.5.0"} ; Make your own parser
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.569"}
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.570"}
|
||||
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
|
||||
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
io.github.metabase/macaw {:mvn/version "0.2.30"} ; Parse native SQL queries
|
||||
io.github.metabase/macaw {:mvn/version "0.2.33"} ; Parse native SQL queries
|
||||
io.netty/netty-codec-http {:mvn/version "4.2.9.Final"} ; override of transitive dep from aws s3
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
|
||||
junegunn/grouper {:mvn/version "0.1.1"} ; Batch processing helper
|
||||
@@ -362,6 +362,8 @@
|
||||
"-Dmb.dangerous.unsafe.enable.testing.h2.connections.do.not.enable=true"
|
||||
;; Ignore connection URI in favor of the MB_DB_FILE
|
||||
"-Dmb.db.connection.uri="
|
||||
;; Unset the premium token so that tests can set it programmatically
|
||||
"-Dmb.premium.embedding.token="
|
||||
;; Ignore config.yml
|
||||
"-Dmb.config.file.path=__cypress__"
|
||||
;; We use Snowplow micro in e2e tests
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
{:current-user-id api/*current-user-id*
|
||||
:current-user-perms @api/*current-user-permissions-set*
|
||||
:is-superuser? api/*is-superuser?*
|
||||
:is-data-analyst api/*is-data-analyst?*
|
||||
:is-impersonated-user? (perms-util/impersonated-user?)
|
||||
:is-sandboxed-user? (perms-util/impersonated-user?)
|
||||
:archived false
|
||||
|
||||
333
docs/api.json
333
docs/api.json
@@ -1273,10 +1273,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1434,10 +1434,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1476,10 +1476,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1515,10 +1515,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1608,10 +1608,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1642,10 +1642,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1692,10 +1692,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1731,10 +1731,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1760,7 +1760,8 @@
|
||||
"display_name" : { },
|
||||
"fields" : { },
|
||||
"name" : { },
|
||||
"schema" : { }
|
||||
"schema" : { },
|
||||
"transform" : { }
|
||||
}
|
||||
},
|
||||
"dependents_count" : {
|
||||
@@ -1770,10 +1771,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1843,10 +1844,10 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"errors" : {
|
||||
"dependents_errors" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.error"
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error"
|
||||
},
|
||||
"uniqueItems" : true
|
||||
},
|
||||
@@ -1874,6 +1875,52 @@
|
||||
"type" : "integer",
|
||||
"minimum" : 1
|
||||
},
|
||||
"metabase-enterprise.dependencies.models.analysis-finding-error.analysis-finding-error" : {
|
||||
"description" : "Schema for an AnalysisFindingError record.",
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"analyzed_entity_id" : {
|
||||
"description" : "value must be an integer greater than zero.",
|
||||
"type" : "integer",
|
||||
"minimum" : 1
|
||||
},
|
||||
"analyzed_entity_type" : {
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.dependency-types.dependency-types"
|
||||
},
|
||||
"error_detail" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "string"
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"error_type" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.validate-error-type"
|
||||
},
|
||||
"id" : {
|
||||
"description" : "value must be an integer greater than zero.",
|
||||
"type" : "integer",
|
||||
"minimum" : 1
|
||||
},
|
||||
"source_entity_id" : {
|
||||
"oneOf" : [ {
|
||||
"description" : "value must be an integer greater than zero.",
|
||||
"type" : "integer",
|
||||
"minimum" : 1
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"source_entity_type" : {
|
||||
"oneOf" : [ {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.source-entity-type"
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
},
|
||||
"required" : [ "id", "analyzed_entity_type", "analyzed_entity_id", "error_type" ]
|
||||
},
|
||||
"metabase-enterprise.metabot-v3.client.schema.message" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
@@ -8536,6 +8583,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"metabase.lib.schema.deduplicate-refs-ignoring-source-field-name-when-possible" : { },
|
||||
"metabase.lib.schema.expression..expression.definition" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.expression.expression"
|
||||
},
|
||||
@@ -8720,6 +8768,8 @@
|
||||
"minItems" : 1
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.util.distinct-mbql-clauses"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.deduplicate-refs-ignoring-source-field-name-when-possible"
|
||||
} ]
|
||||
},
|
||||
"metabase.lib.schema.filter.string-filter-options" : {
|
||||
@@ -11179,88 +11229,14 @@
|
||||
},
|
||||
"metabase.lib.schema.util.distinct-mbql-clauses" : { },
|
||||
"metabase.lib.schema.util.unique-uuids" : { },
|
||||
"metabase.lib.schema.validate.duplicate-column-error" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"name" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"type" : {
|
||||
"const" : "validate/duplicate-column"
|
||||
}
|
||||
},
|
||||
"required" : [ "type", "name" ]
|
||||
},
|
||||
"metabase.lib.schema.validate.error" : {
|
||||
"allOf" : [ {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"type" : {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.validate-error-type"
|
||||
}
|
||||
},
|
||||
"required" : [ "type" ]
|
||||
}, {
|
||||
"oneOf" : [ {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.missing-column-error"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.missing-table-alias-error"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.duplicate-column-error"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.syntax-error"
|
||||
}, {
|
||||
"$ref" : "#/components/schemas/metabase.lib.schema.validate.validation-exception-error"
|
||||
} ]
|
||||
} ]
|
||||
},
|
||||
"metabase.lib.schema.validate.missing-column-error" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"name" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"type" : {
|
||||
"const" : "validate/missing-column"
|
||||
}
|
||||
},
|
||||
"required" : [ "type", "name" ]
|
||||
},
|
||||
"metabase.lib.schema.validate.missing-table-alias-error" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"name" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"type" : {
|
||||
"const" : "validate/missing-table-alias"
|
||||
}
|
||||
},
|
||||
"required" : [ "type", "name" ]
|
||||
},
|
||||
"metabase.lib.schema.validate.syntax-error" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"type" : {
|
||||
"const" : "validate/syntax-error"
|
||||
}
|
||||
},
|
||||
"required" : [ "type" ]
|
||||
"metabase.lib.schema.validate.source-entity-type" : {
|
||||
"description" : "The type of the source entity causing an error.",
|
||||
"type" : "string",
|
||||
"enum" : [ "table", "card" ]
|
||||
},
|
||||
"metabase.lib.schema.validate.validate-error-type" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"metabase.lib.schema.validate.validation-exception-error" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"message" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"type" : {
|
||||
"const" : "validate/validation-exception-error"
|
||||
}
|
||||
},
|
||||
"required" : [ "type", "message" ]
|
||||
"type" : "string",
|
||||
"enum" : [ "missing-column", "missing-table-alias", "duplicate-column", "syntax-error", "validation-exception-error" ]
|
||||
},
|
||||
"metabase.logger.api.log-level" : {
|
||||
"type" : "string",
|
||||
@@ -12585,6 +12561,10 @@
|
||||
"type" : "string",
|
||||
"enum" : [ "alert", "sync", "fingerprint", "subscription" ]
|
||||
},
|
||||
"started-at" : {
|
||||
"type" : "string",
|
||||
"minLength" : 1
|
||||
},
|
||||
"status" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "started", "success", "failed", "abandoned" ]
|
||||
@@ -22689,7 +22669,7 @@
|
||||
"/api/ee/dependencies/graph/broken" : {
|
||||
"get" : {
|
||||
"summary" : "GET /api/ee/dependencies/graph/broken",
|
||||
"description" : "Returns a list of all items with broken queries.\n\n Accepts optional parameters for filtering:\n - `types`: List of entity types to include (e.g., `[:card :transform :snippet :dashboard]`)\n - `card_types`: List of card types to include when filtering cards (e.g., `[:question :model :metric]`)\n - `query`: Search string to filter by name or location\n - `archived`: Controls whether archived entities are included\n - `include_personal_collections`: Controls whether items in personal collections are included (default: false)\n - `sort_column`: Sort column - `:name`, `:location`, or `:dependents-count` (default: `:name`)\n - `sort_direction`: Sort direction - `:asc` or `:desc` (default: `:asc`)\n - `offset`: Default 0\n - `limit`: Default 50\n\n Returns a map with:\n - `data`: List of broken items, each with `:id`, `:type`, `:data`, and `:error`s fields\n - `total`: Total count of matched items\n - `offset`: Applied offset\n - `limit`: Applied limit",
|
||||
"description" : "Returns a list of entities that are causing errors in downstream dependents\n These are tables or cards that other entities depend on, where those dependents\n have validation errors traced back to this source entity.\n\n Accepts optional parameters for filtering:\n - `types`: List of source entity types - only `:card` or `:table` (default: both)\n - `card_types`: List of card types to include when filtering cards (e.g., `[:question :model :metric]`)\n - `query`: Search string to filter by name or location\n - `archived`: Controls whether archived entities are included\n - `include_personal_collections`: Controls whether items in personal collections are included (default: false)\n - `sort_column`: Sort column - `:name`, `:location`, `:dependents-errors`, or `:dependents-with-errors` (default: `:name`)\n - `sort_direction`: Sort direction - `:asc` or `:desc` (default: `:asc`)\n - `offset`: Default 0\n - `limit`: Default 50\n\n Returns a map with:\n - `data`: List of breaking source entities\n - `total`: Total count of matched items\n - `offset`: Applied offset\n - `limit`: Applied limit",
|
||||
"parameters" : [ {
|
||||
"in" : "query",
|
||||
"name" : "types",
|
||||
@@ -22747,7 +22727,7 @@
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "name", "location", "dependents-count" ]
|
||||
"enum" : [ "dependents-with-errors", "dependents-errors", "name", "location" ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
@@ -22804,7 +22784,7 @@
|
||||
"/api/ee/dependencies/graph/dependents" : {
|
||||
"get" : {
|
||||
"summary" : "GET /api/ee/dependencies/graph/dependents",
|
||||
"description" : "This endpoint takes an :id, :type, :dependent_type, and an optional :dependent_card_type, and returns a list of\n all that entity's dependents with :dependent_type. If the :dependent_type is :card, the dependents are further\n filtered by :dependent_card_type.\n\n Optional :archived parameter controls whether entities in archived collections are included:\n - false (default): Excludes entities in archived collections\n - true: Includes entities in archived collections",
|
||||
"description" : "Returns a list of dependents for the specified entity.\n\n Required parameters:\n - `id`: The ID of the entity\n - `type`: The type of the entity (card, table, dashboard, etc.)\n\n Optional parameters:\n - `dependent_types`: Dependency types to filter by. Can be single value or array.\n If not provided, returns all types. Example: ?dependent_types=card&dependent_types=dashboard\n - `dependent_card_types`: Card types to filter by when dependent_types includes :card.\n Ignored if dependent_types doesn't include :card. Example: ?dependent_card_types=question&dependent_card_types=model\n - `archived`: Include entities in archived collections (default: false)\n - `broken`: Return only broken entities (default: false)\n - `query`: Search string to filter results by name or location (case-insensitive)\n - `include_personal_collections`: Include items in personal collections (default: false)\n - `sort_column`: Column to sort by - name, location, or view-count (default: name)\n - `sort_direction`: Sort direction - asc or desc (default: asc)",
|
||||
"parameters" : [ {
|
||||
"in" : "query",
|
||||
"name" : "id",
|
||||
@@ -22823,18 +22803,33 @@
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "dependent_type",
|
||||
"required" : true,
|
||||
"name" : "dependent_types",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.dependency-types.dependency-types"
|
||||
"anyOf" : [ {
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.dependency-types.dependency-types"
|
||||
}, {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"$ref" : "#/components/schemas/metabase-enterprise.dependencies.dependency-types.dependency-types"
|
||||
}
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "dependent_card_type",
|
||||
"name" : "dependent_card_types",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "question", "metric", "model" ]
|
||||
"anyOf" : [ {
|
||||
"type" : "string",
|
||||
"enum" : [ "question", "metric", "model" ]
|
||||
}, {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "question", "metric", "model" ]
|
||||
}
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
@@ -22843,6 +22838,43 @@
|
||||
"schema" : {
|
||||
"type" : "boolean"
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "broken",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "boolean"
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "query",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "string"
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "include_personal_collections",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "boolean"
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "sort_column",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "name", "view-count", "location" ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "sort_direction",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "desc", "asc" ]
|
||||
}
|
||||
} ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
@@ -22871,7 +22903,7 @@
|
||||
"/api/ee/dependencies/graph/unreferenced" : {
|
||||
"get" : {
|
||||
"summary" : "GET /api/ee/dependencies/graph/unreferenced",
|
||||
"description" : "Returns a list of all unreferenced items in the instance.\n An unreferenced item is one that is not a dependency of any other item.\n\n Accepts optional parameters for filtering:\n - `types`: List of entity types to include (e.g., [:card :transform :snippet :dashboard])\n - `card_types`: List of card types to include when filtering cards (e.g., [:question :model :metric])\n - `query`: Search string to filter by name or location\n - `archived`: Controls whether archived entities are included\n - `include_personal_collections`: Controls whether items in personal collections are included (default: false)\n - `sort_column`: Sort column - `:name`, `:location`, or `:dependents-count` (default: `:name`)\n - `sort_direction`: Sort direction - `:asc` or `:desc` (default: `:asc`)\n - `offset`: Default 0\n - `limit`: Default 50\n\n Returns a map with:\n - `data`: List of unreferenced items, each with `:id`, `:type`, and `:data` fields\n - `total`: Total count of matched items\n - `offset`: Applied offset\n - `limit`: Applied limit",
|
||||
"description" : "Returns a list of all unreferenced items in the instance.\n An unreferenced item is one that is not a dependency of any other item.\n\n Accepts optional parameters for filtering:\n - `types`: List of entity types to include (e.g., [:card :transform :snippet :dashboard])\n - `card_types`: List of card types to include when filtering cards (e.g., [:question :model :metric])\n - `query`: Search string to filter by name or location\n - `archived`: Controls whether archived entities are included\n - `include_personal_collections`: Controls whether items in personal collections are included (default: false)\n - `sort_column`: Sort column - `:name`, `:location`, `:dependents-errors`, or `:dependents-with-errors` (default: `:name`)\n - `sort_direction`: Sort direction - `:asc` or `:desc` (default: `:asc`)\n - `offset`: Default 0\n - `limit`: Default 50\n\n Returns a map with:\n - `data`: List of unreferenced items, each with `:id`, `:type`, and `:data` fields\n - `total`: Total count of matched items\n - `offset`: Applied offset\n - `limit`: Applied limit",
|
||||
"parameters" : [ {
|
||||
"in" : "query",
|
||||
"name" : "types",
|
||||
@@ -22929,7 +22961,7 @@
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"type" : "string",
|
||||
"enum" : [ "name", "location", "dependents-count" ]
|
||||
"enum" : [ "dependents-with-errors", "dependents-errors", "name", "location" ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
@@ -23225,7 +23257,7 @@
|
||||
"/api/ee/library/" : {
|
||||
"post" : {
|
||||
"summary" : "POST /api/ee/library/",
|
||||
"description" : "Creates the Library if it doesn't exist. Returns the created collection.\n\n Requires superuser permissions.",
|
||||
"description" : "Creates the Library if it doesn't exist. Returns the created collection.\n\n Requires data analyst or superuser permissions.",
|
||||
"parameters" : [ ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
@@ -26065,6 +26097,13 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"remote-sync-auto-import" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "boolean"
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"remote-sync-branch" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "string"
|
||||
@@ -26079,6 +26118,13 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"remote-sync-transforms" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "boolean"
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"remote-sync-type" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "string",
|
||||
@@ -28168,31 +28214,11 @@
|
||||
"/api/ee/transform/extract-columns" : {
|
||||
"post" : {
|
||||
"summary" : "POST /api/ee/transform/extract-columns",
|
||||
"description" : "Extract column names suitable for incremental transform checkpoint filtering.\n\n This endpoint is specifically for populating the checkpoint column dropdown in\n incremental transforms. It only returns columns with types supported for checkpoint\n filtering: temporal (timestamp/tz) and numeric (int/float) types.\n\n Text, boolean, and other unsupported column types are filtered out.\n\n The query is compiled to native SQL using [[qp.compile/compile-with-inline-parameters]],\n which handles parameterized queries with template tags. Then extracts column names\n and types using PreparedStatement metadata.\n\n Returns a map with a :columns key containing a vector of column names (strings).\n If extraction fails, returns nil for :columns.",
|
||||
"description" : "Extract column names from an MBQL query without executing it.\n\n Compiles the query to native SQL using [[qp.compile/compile-with-inline-parameters]],\n which handles parameterized queries with template tags. Then extracts column names\n using PreparedStatement metadata.\n\n Returns a map with a :columns key containing a vector of column names (strings).\n If extraction fails, returns nil for :columns.",
|
||||
"parameters" : [ ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
"description" : "Successful response",
|
||||
"content" : {
|
||||
"application/json" : {
|
||||
"schema" : {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"columns" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "string"
|
||||
}
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
},
|
||||
"required" : [ "columns" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
"description" : "Successful response"
|
||||
},
|
||||
"4XX" : {
|
||||
"description" : "Client error response"
|
||||
@@ -37446,6 +37472,14 @@
|
||||
"type" : "string",
|
||||
"enum" : [ "alert", "sync", "fingerprint", "subscription" ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "started-at",
|
||||
"required" : true,
|
||||
"schema" : {
|
||||
"type" : "string",
|
||||
"minLength" : 1
|
||||
}
|
||||
} ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
@@ -38655,6 +38689,28 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "is_data_analyst",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "boolean"
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "can_access_data_studio",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "boolean"
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "tenancy",
|
||||
@@ -38901,6 +38957,13 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"is_data_analyst" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "boolean"
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
},
|
||||
"locale" : {
|
||||
"oneOf" : [ {
|
||||
"description" : "String must be a valid two-letter ISO language or language-country code e.g. 'en' or 'en_US'.",
|
||||
|
||||
@@ -224,6 +224,7 @@ config:
|
||||
remote-sync-auto-import-rate: 5
|
||||
remote-sync-check-changes-cache-ttl-seconds: 60
|
||||
remote-sync-task-time-limit-ms: 300000
|
||||
remote-sync-transforms: false
|
||||
report-timezone: null
|
||||
reset-token-ttl-hours: 48
|
||||
retry-initial-interval: 500
|
||||
@@ -271,6 +272,12 @@ config:
|
||||
site-url: null
|
||||
slack-app-token: null
|
||||
slack-bug-report-channel: metabase-bugs
|
||||
slack-connect-attribute-team-id: https://slack.com/team_id
|
||||
slack-connect-authentication-mode: sso
|
||||
slack-connect-client-id: null
|
||||
slack-connect-client-secret: null
|
||||
slack-connect-enabled: false
|
||||
slack-connect-user-provisioning-enabled: true
|
||||
smtp-override-enabled: false
|
||||
source-address-header: X-Forwarded-For
|
||||
sql-jdbc-fetch-size: 500
|
||||
|
||||
@@ -1385,6 +1385,14 @@ Time-to-live in seconds for the remote changes check cache. Default is 60 second
|
||||
|
||||
The maximum amount of time a remote sync task will be given to complete.
|
||||
|
||||
### `MB_REMOTE_SYNC_TRANSFORMS`
|
||||
|
||||
- Type: boolean
|
||||
- Default: `false`
|
||||
- [Configuration file name](./config-file.md): `remote-sync-transforms`
|
||||
|
||||
Whether to sync transforms via remote-sync. When enabled, all transforms, transform tags, and transform jobs are synced as a single unit (all-or-nothing).
|
||||
|
||||
### `MB_REPORT_TIMEZONE`
|
||||
|
||||
- Type: string
|
||||
@@ -1831,6 +1839,66 @@ Bot user OAuth token for connecting the Metabase Slack app. This should be used
|
||||
|
||||
The name of the channel where bug reports should be posted.
|
||||
|
||||
### `MB_SLACK_CONNECT_ATTRIBUTE_TEAM_ID`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: string
|
||||
- Default: `https://slack.com/team_id`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-attribute-team-id`
|
||||
|
||||
Slack OIDC claim for the team/workspace ID.
|
||||
|
||||
### `MB_SLACK_CONNECT_AUTHENTICATION_MODE`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: string
|
||||
- Default: `sso`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-authentication-mode`
|
||||
|
||||
Controls whether Slack can be used for SSO login or just account linking. Valid values: "sso" (default) or "link-only".
|
||||
|
||||
### `MB_SLACK_CONNECT_CLIENT_ID`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: string
|
||||
- Default: `null`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-client-id`
|
||||
|
||||
Client ID for your Slack app. Get this from https://api.slack.com/apps.
|
||||
|
||||
### `MB_SLACK_CONNECT_CLIENT_SECRET`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: string
|
||||
- Default: `null`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-client-secret`
|
||||
|
||||
Client Secret for your Slack app.
|
||||
|
||||
### `MB_SLACK_CONNECT_ENABLED`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: boolean
|
||||
- Default: `false`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-enabled`
|
||||
|
||||
Is Slack Connect authentication configured and enabled?
|
||||
|
||||
### `MB_SLACK_CONNECT_USER_PROVISIONING_ENABLED`
|
||||
|
||||
> Only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
- Type: boolean
|
||||
- Default: `true`
|
||||
- [Configuration file name](./config-file.md): `slack-connect-user-provisioning-enabled`
|
||||
|
||||
When a user logs in via Slack Connect, create a Metabase account for them automatically if they don't have one.
|
||||
|
||||
### `MB_SLACK_FILES_CHANNEL [DEPRECATED]`
|
||||
|
||||
> DEPRECATED: 0.54.0
|
||||
|
||||
@@ -15,8 +15,12 @@ To restrict data in guest embeds for specific people or groups, use [locked para
|
||||
|
||||
## Turning on guest embedding in Metabase
|
||||
|
||||
1. Go to **Settings > Admin settings > Embedding > Guest embeds**.
|
||||
2. Toggle **Enable guest embedding**.
|
||||
The path to embedding settings depends on your Metabase version:
|
||||
|
||||
- **OSS**: **Admin settings > Embedding**
|
||||
- **Starter/Pro/Enterprise**: **Admin settings > Embedding > Guest embeds**
|
||||
|
||||
Toggle **Enable guest embeds**.
|
||||
|
||||
## Creating a guest embed
|
||||
|
||||
@@ -240,14 +244,14 @@ The end user won't see the "category" filter, but the dashboard will only show d
|
||||
|
||||
## Disabling embedding for a question or dashboard
|
||||
|
||||
You can find a list of all guest embeds of questions and dashboards from **Admin settings** > **Embedding** > **Guest embeds**.
|
||||
|
||||
1. Visit the embeddable question or dashboard.
|
||||
2. Click the **sharing icon** (square with an arrow pointing to the top right).
|
||||
3. Select **Embed**.
|
||||
4. Select **Guest embedding**
|
||||
5. Click **Unpublish**.
|
||||
|
||||
Admins can find a list of embedded items in **Admin settings > Embedding** (on Pro and Enterprise plans, check the **Guest embeds** tab).
|
||||
|
||||
## Removing the "Powered by Metabase" banner
|
||||
|
||||

|
||||
@@ -258,7 +262,7 @@ The banner appears on guest embeds created with Metabase's open-source version.
|
||||
|
||||
Your embedding secret key is used to sign JWTs for all of your embeds.
|
||||
|
||||
1. Go to **Admin** > **Embedding** > **Guest embeds**.
|
||||
1. Go to **Admin settings > Embedding**. On Pro and Enterprise plans, check the **Guest embeds** tab.
|
||||
2. Under **Regenerate secret key**, click **Regenerate key**.
|
||||
|
||||
This key is shared across all guest embeds. Whoever has access to this key could get access to all embedded artifacts, so keep this key secure. If you regenerate this key, you'll need to update your server code with the new key.
|
||||
|
||||
@@ -17,6 +17,9 @@ const options = {
|
||||
GENERATE_SNAPSHOTS: true,
|
||||
JAR_PATH: undefined,
|
||||
...booleanify(process.env),
|
||||
// If this token is present in your env when Cypress runs, it's a mistake.
|
||||
// Cypress needs to start from a clean slate in order to set the token programmatically.
|
||||
MB_PREMIUM_EMBEDDING_TOKEN: undefined,
|
||||
};
|
||||
|
||||
process.env = unBooleanify(options);
|
||||
|
||||
@@ -125,22 +125,22 @@ describe("snapshots", () => {
|
||||
// groups
|
||||
cy.request("POST", "/api/permissions/group", { name: "collection" }).then(
|
||||
({ body }) => {
|
||||
expect(body.id).to.eq(COLLECTION_GROUP); // 4
|
||||
expect(body.id).to.eq(COLLECTION_GROUP); // 5
|
||||
},
|
||||
);
|
||||
cy.request("POST", "/api/permissions/group", { name: "data" }).then(
|
||||
({ body }) => {
|
||||
expect(body.id).to.eq(DATA_GROUP); // 5
|
||||
expect(body.id).to.eq(DATA_GROUP); // 6
|
||||
},
|
||||
);
|
||||
cy.request("POST", "/api/permissions/group", { name: "readonly" }).then(
|
||||
({ body }) => {
|
||||
expect(body.id).to.eq(READONLY_GROUP); // 6
|
||||
expect(body.id).to.eq(READONLY_GROUP); // 7
|
||||
},
|
||||
);
|
||||
cy.request("POST", "/api/permissions/group", { name: "nosql" }).then(
|
||||
({ body }) => {
|
||||
expect(body.id).to.eq(NOSQL_GROUP); // 7
|
||||
expect(body.id).to.eq(NOSQL_GROUP); // 8
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -46,6 +46,67 @@ const assetsResolverPlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
// Plugin to allow loading the ClojureScript bundle only when it exists
|
||||
// on the filesystem. This is to avoid breaking the build when the bundle
|
||||
// is not present.
|
||||
//
|
||||
// If the bundle is not present, tests relying on it will fail, but the Cypress
|
||||
// process will be allowed to start.
|
||||
//
|
||||
// The error will look like this:
|
||||
//
|
||||
// Dynamic require of "cljs/metabase.types.core" is not supported
|
||||
//
|
||||
// This allows us to avoid building the ClojureScript bundle when we don't need to (like in certain CI tasks).
|
||||
const virtualClojureScriptPlugin = {
|
||||
name: "virtualClojureScriptBundle",
|
||||
setup(build) {
|
||||
let cljsDir = null;
|
||||
|
||||
async function isDirectory(path) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(path);
|
||||
return stat.isDirectory();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
build.onStart(async () => {
|
||||
const baseDir = path.join(__dirname, "../../target");
|
||||
const devDir = path.join(baseDir, "cljs_dev");
|
||||
const prodDir = path.join(baseDir, "cljs_prod");
|
||||
|
||||
const [devDirExists, prodDirExists] = await Promise.all([
|
||||
isDirectory(devDir),
|
||||
isDirectory(prodDir),
|
||||
]);
|
||||
|
||||
if (prodDirExists) {
|
||||
console.log("ClojureScript production bundle found");
|
||||
cljsDir = prodDir;
|
||||
} else if (devDirExists) {
|
||||
console.log("ClojureScript development bundle found");
|
||||
cljsDir = devDir;
|
||||
} else {
|
||||
console.log("No ClojureScript bundle found, not loading bundle");
|
||||
cljsDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
build.onResolve({ filter: /^cljs\// }, async (args) => {
|
||||
if (cljsDir === null) {
|
||||
return {
|
||||
path: args.path,
|
||||
external: true,
|
||||
};
|
||||
}
|
||||
const unnested = args.path.split(path.sep).slice(1).join(path.sep);
|
||||
return { path: path.join(cljsDir, unnested + ".js") };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const defaultConfig = {
|
||||
// This is the functionality of the old cypress-plugins.js file
|
||||
setupNodeEvents(cypressOn, config) {
|
||||
@@ -75,7 +136,11 @@ const defaultConfig = {
|
||||
loader: {
|
||||
".svg": "text",
|
||||
},
|
||||
plugins: [NodeModulesPolyfillPlugin(), assetsResolverPlugin],
|
||||
plugins: [
|
||||
NodeModulesPolyfillPlugin(),
|
||||
assetsResolverPlugin,
|
||||
virtualClojureScriptPlugin,
|
||||
],
|
||||
sourcemap: "inline",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -43,14 +43,15 @@ export const SAMPLE_DB_TABLES = {
|
||||
export const USER_GROUPS = {
|
||||
ALL_USERS_GROUP: 1,
|
||||
ADMIN_GROUP: 2,
|
||||
COLLECTION_GROUP: 4,
|
||||
DATA_GROUP: 5,
|
||||
READONLY_GROUP: 6,
|
||||
NOSQL_GROUP: 7,
|
||||
COLLECTION_GROUP: 5,
|
||||
DATA_GROUP: 6,
|
||||
READONLY_GROUP: 7,
|
||||
NOSQL_GROUP: 8,
|
||||
};
|
||||
|
||||
export const TENANT_USER_GROUPS = {
|
||||
export const MAGIC_USER_GROUPS = {
|
||||
EXTERNAL_USERS_GROUP: 3,
|
||||
DATA_ANALYSTS_GROUP: 4,
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
@@ -156,6 +156,10 @@ export const ALL_EXTERNAL_USERS_GROUP_ID = _.findWhere(
|
||||
},
|
||||
)?.id;
|
||||
|
||||
export const DATA_ANALYSTS_GROUP_ID = _.findWhere(SAMPLE_INSTANCE_DATA.groups, {
|
||||
magic_group_type: "data-analyst",
|
||||
})?.id;
|
||||
|
||||
export const COLLECTION_GROUP_ID = _.findWhere(SAMPLE_INSTANCE_DATA.groups, {
|
||||
name: "collection",
|
||||
}).id;
|
||||
|
||||
@@ -38,6 +38,7 @@ export const DataStudio = {
|
||||
},
|
||||
PythonLibrary: {
|
||||
header: () => cy.findByTestId("python-library-header"),
|
||||
editor: () => cy.findByTestId("python-editor"),
|
||||
},
|
||||
Snippets: {
|
||||
newPage: newSnippetPage,
|
||||
@@ -131,26 +132,4 @@ export const DataStudio = {
|
||||
collectionItem: (name: string) =>
|
||||
libraryPage().findAllByTestId("collection-name").contains(name),
|
||||
},
|
||||
Tasks: {
|
||||
visitBrokenEntities: () => cy.visit("/data-studio/tasks/broken"),
|
||||
visitUnreferencedEntities: () =>
|
||||
cy.visit("/data-studio/tasks/unreferenced"),
|
||||
list: () => cy.findByTestId("dependency-list"),
|
||||
searchInput: () => cy.findByTestId("dependency-list-search-input"),
|
||||
filterButton: () => cy.findByTestId("dependency-list-filter-button"),
|
||||
sidebar: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
|
||||
Sidebar: {
|
||||
get: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
header: () => cy.findByTestId("dependency-list-sidebar-header"),
|
||||
locationInfo: () => cy.findByRole("region", { name: "Location" }),
|
||||
transformInfo: () => cy.findByRole("region", { name: "Transform" }),
|
||||
missingColumnsInfo: () =>
|
||||
cy.findByRole("region", { name: "Missing columns" }),
|
||||
creationInfo: () =>
|
||||
cy.findByRole("region", { name: "Creator and last editor" }),
|
||||
brokenDependentsInfo: () =>
|
||||
cy.findByRole("region", { name: "Broken dependents" }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export const DependencyGraph = {
|
||||
graph: () => cy.findByTestId("dependency-graph"),
|
||||
entryButton: () => cy.findByTestId("graph-entry-button"),
|
||||
entrySearchInput: () => cy.findByTestId("graph-entry-search-input"),
|
||||
selectionButton: () => cy.findByTestId("graph-selection-button"),
|
||||
dependencyPanel: () => cy.findByTestId("graph-dependency-panel"),
|
||||
};
|
||||
31
e2e/support/helpers/e2e-dependency-helpers.ts
Normal file
31
e2e/support/helpers/e2e-dependency-helpers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export const DependencyGraph = {
|
||||
graph: () => cy.findByTestId("dependency-graph"),
|
||||
entryButton: () => cy.findByTestId("graph-entry-button"),
|
||||
entrySearchInput: () => cy.findByTestId("graph-entry-search-input"),
|
||||
selectionButton: () => cy.findByTestId("graph-selection-button"),
|
||||
dependencyPanel: () => cy.findByTestId("graph-dependency-panel"),
|
||||
};
|
||||
|
||||
export const DependencyDiagnostics = {
|
||||
visitBrokenDependencies: () =>
|
||||
cy.visit("/data-studio/dependency-diagnostics/broken"),
|
||||
visitUnreferencedEntities: () =>
|
||||
cy.visit("/data-studio/dependency-diagnostics/unreferenced"),
|
||||
list: () => cy.findByTestId("dependency-list"),
|
||||
searchInput: () => cy.findByTestId("dependency-list-search-input"),
|
||||
filterButton: () => cy.findByTestId("dependency-filter-button"),
|
||||
sidebar: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
|
||||
Sidebar: {
|
||||
get: () => cy.findByTestId("dependency-list-sidebar"),
|
||||
header: () => cy.findByTestId("dependency-list-sidebar-header"),
|
||||
locationSection: () => cy.findByRole("region", { name: "Location" }),
|
||||
transformSection: () => cy.findByRole("region", { name: "Transform" }),
|
||||
missingColumnsSection: () =>
|
||||
cy.findByRole("region", { name: "Missing columns" }),
|
||||
creationSection: () =>
|
||||
cy.findByRole("region", { name: "Creator and last editor" }),
|
||||
brokenDependentsSection: () =>
|
||||
cy.findByRole("region", { name: "Broken dependents" }),
|
||||
},
|
||||
};
|
||||
@@ -3,6 +3,11 @@ interface User {
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
}
|
||||
|
||||
export function setUserAsAnalyst(userId: number) {
|
||||
cy.request("PUT", `/api/user/${userId}`, { is_data_analyst: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's full name, or an email address if name is not available.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./api";
|
||||
export * from "./mbql";
|
||||
export * from "./e2e-action-helpers";
|
||||
export * from "./e2e-ad-hoc-question-helpers";
|
||||
export * from "./e2e-api-key-helpers";
|
||||
@@ -16,7 +17,7 @@ export * from "./e2e-dashboard-visualizer-helpers";
|
||||
export * from "./e2e-database-metadata-helpers";
|
||||
export * from "./e2e-datamodel-helpers";
|
||||
export * from "./e2e-data-studio-helpers";
|
||||
export * from "./e2e-dependency-graph-helpers";
|
||||
export * from "./e2e-dependency-helpers";
|
||||
export * from "./e2e-detail-view-helpers";
|
||||
export * from "./e2e-dimension-list-helpers";
|
||||
export * from "./e2e-document-helpers";
|
||||
|
||||
6
e2e/support/helpers/mbql/index.ts
Normal file
6
e2e/support/helpers/mbql/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getMetadataProvider,
|
||||
createQuery,
|
||||
createNativeQuery,
|
||||
} from "./wrappers";
|
||||
export { createCardWithQuery, createCardWithNativeQuery } from "./question";
|
||||
86
e2e/support/helpers/mbql/metadata-provider.ts
Normal file
86
e2e/support/helpers/mbql/metadata-provider.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// WARNING:
|
||||
// This module should not be imported directly in cypress
|
||||
// harnesses, use an dynamic import instead.
|
||||
import { getMetadataWithoutSettings as getMetadataFromState } from "metabase/selectors/metadata";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type {
|
||||
Card,
|
||||
CardId,
|
||||
Database,
|
||||
Table,
|
||||
TableId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { createMockEntitiesState } from "../../../../frontend/test/__support__/store";
|
||||
import { SAMPLE_DB_ID, SAMPLE_DB_TABLES } from "../../cypress_data";
|
||||
|
||||
import type { GetMetadataOpts } from "./types";
|
||||
|
||||
const DEFAULT_TABLE_IDS: TableId[] = [
|
||||
SAMPLE_DB_TABLES.STATIC_ORDERS_ID,
|
||||
SAMPLE_DB_TABLES.STATIC_PEOPLE_ID,
|
||||
SAMPLE_DB_TABLES.STATIC_PRODUCTS_ID,
|
||||
SAMPLE_DB_TABLES.STATIC_REVIEWS_ID,
|
||||
];
|
||||
const DEFAULT_CARD_IDS: CardId[] = [];
|
||||
|
||||
/**
|
||||
* Returns a Lib.MetadataProvider instance containing metadata for the
|
||||
* provided database, tables and cards.
|
||||
*
|
||||
* WARNING:
|
||||
* DO NOT USE THIS DIRECTLY IN TESTS.
|
||||
* Use the `getMetadataProvider` from ./wrappers" instead.
|
||||
*
|
||||
* NOTE:
|
||||
* This function uses plain Promises and Promise.all to avoid type issues
|
||||
* with Cypress. The result needs to wrapped in cy.wrap() to be correctly usable
|
||||
* in Cypress tests.
|
||||
*/
|
||||
export async function getMetadataProvider({
|
||||
databaseId = SAMPLE_DB_ID,
|
||||
...rest
|
||||
}: GetMetadataOpts = {}) {
|
||||
const metadata = await getMetadata({ databaseId, ...rest });
|
||||
return Lib.metadataProvider(databaseId, metadata);
|
||||
}
|
||||
|
||||
async function getMetadata({
|
||||
databaseId = SAMPLE_DB_ID,
|
||||
tableIds = DEFAULT_TABLE_IDS,
|
||||
cardIds = DEFAULT_CARD_IDS,
|
||||
}: GetMetadataOpts = {}) {
|
||||
// Just one database for now, but wrapped to get the types to work
|
||||
const databaseIds = [databaseId];
|
||||
|
||||
const [databases, tables, cards] = await Promise.all([
|
||||
// databases
|
||||
Promise.all(
|
||||
databaseIds.map((databaseId) =>
|
||||
GET<Database>(`/api/database/${databaseId}`),
|
||||
),
|
||||
),
|
||||
|
||||
// cards
|
||||
Promise.all(
|
||||
tableIds.map((tableId) =>
|
||||
GET<Table>(`/api/table/${tableId}/query_metadata`),
|
||||
),
|
||||
),
|
||||
|
||||
// tables
|
||||
Promise.all(cardIds.map((cardId) => GET<Card>(`/api/card/${cardId}`))),
|
||||
]);
|
||||
|
||||
const entities = createMockEntitiesState({
|
||||
databases,
|
||||
tables,
|
||||
questions: cards,
|
||||
});
|
||||
return getMetadataFromState({ entities });
|
||||
}
|
||||
|
||||
async function GET<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
}
|
||||
104
e2e/support/helpers/mbql/question.ts
Normal file
104
e2e/support/helpers/mbql/question.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type { CreateTestQueryOpts } from "metabase-lib/test-helpers";
|
||||
import type { DatabaseId } from "metabase-types/api";
|
||||
|
||||
import type { QuestionDetails } from "../api";
|
||||
import { type Options, question } from "../api/createQuestion";
|
||||
|
||||
import type { GetMetadataOpts } from "./types";
|
||||
import { createNativeQuery, createQuery } from "./wrappers";
|
||||
|
||||
/**
|
||||
* Create a card using the provided details and a MBQL query.
|
||||
*
|
||||
* @param details.metadata -
|
||||
* The metadata provider to use for the query,
|
||||
* or options used to create one on the fly.
|
||||
* If no metadata is provided, the query will be created with
|
||||
* the default metadata provider.
|
||||
*
|
||||
* @example
|
||||
* // If no metadata is provided, the card will be created with
|
||||
* // the default metadata provider.
|
||||
* H.createTestQuestion({
|
||||
* name: "My question",
|
||||
* query: {
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* stages: [
|
||||
* ...
|
||||
* ]
|
||||
* }
|
||||
* })
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Provide custom metadata entities relevant to your query.
|
||||
* H.createTestQuestion({
|
||||
* name: "My question",
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* query: "SELECT * FROM orders"
|
||||
* })
|
||||
* })
|
||||
*
|
||||
*/
|
||||
|
||||
type CardWithQueryDetails = Omit<QuestionDetails, "dataset_query"> & {
|
||||
metadata?: GetMetadataOpts | Lib.MetadataProvider;
|
||||
query: CreateTestQueryOpts;
|
||||
};
|
||||
|
||||
export function createCardWithQuery(
|
||||
{ metadata = {}, query, ...details }: CardWithQueryDetails,
|
||||
options: Options = {},
|
||||
) {
|
||||
return createQuery(metadata, query).then((dataset_query) =>
|
||||
question({ ...details, dataset_query }, options),
|
||||
);
|
||||
}
|
||||
|
||||
type CardWithNativeQueryDetails = Omit<QuestionDetails, "dataset_query"> & {
|
||||
metadata?: GetMetadataOpts | Lib.MetadataProvider;
|
||||
databaseId: DatabaseId;
|
||||
query: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a card using the provided details and a native query.
|
||||
*
|
||||
* @param details.metadata -
|
||||
* The metadata provider to use for the query,
|
||||
* or options used to create one on the fly.
|
||||
* If no metadata is provided, the query will be created with
|
||||
* the default metadata provider.
|
||||
*
|
||||
* @example
|
||||
* // If no metadata is provided, the card will be created with
|
||||
* // the default metadata provider.
|
||||
* H.createCardWithNativeQuery({
|
||||
* name: "My question",
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* query: "SELECT * FROM orders"
|
||||
* })
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Provide custom metadata entities relevant to your query.
|
||||
* H.createCardWithNativeQuery({
|
||||
* name: "My question",
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* metadata: {
|
||||
* databaseId: SAMPLE_DATABASE_ID,
|
||||
* cardIds: [SAMPLE_CARD_ID],
|
||||
* },
|
||||
* query: "SELECT * FROM orders"
|
||||
* })
|
||||
* })
|
||||
*/
|
||||
export function createCardWithNativeQuery(
|
||||
{ metadata = {}, databaseId, query, ...details }: CardWithNativeQueryDetails,
|
||||
options: Options = {},
|
||||
) {
|
||||
return createNativeQuery(metadata, databaseId, query).then((dataset_query) =>
|
||||
question({ ...details, dataset_query }, options),
|
||||
);
|
||||
}
|
||||
7
e2e/support/helpers/mbql/types.ts
Normal file
7
e2e/support/helpers/mbql/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CardId, DatabaseId, TableId } from "metabase-types/api";
|
||||
|
||||
export type GetMetadataOpts = {
|
||||
databaseId?: DatabaseId;
|
||||
tableIds?: TableId[];
|
||||
cardIds?: CardId[];
|
||||
};
|
||||
103
e2e/support/helpers/mbql/wrappers.ts
Normal file
103
e2e/support/helpers/mbql/wrappers.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type { CreateTestQueryOpts } from "metabase-lib/test-helpers";
|
||||
import type { DatabaseId, OpaqueDatasetQuery } from "metabase-types/api";
|
||||
|
||||
import type { GetMetadataOpts } from "./types";
|
||||
|
||||
/**
|
||||
* Returns a Lib.MetadataProvider instance containing metadata for the
|
||||
* entities defined in opts.
|
||||
*
|
||||
* @param opts.databaseId - The database to fetch metadata for
|
||||
* @param opts.tableIds - The tables to fetch metadata for
|
||||
* @param opts.cardIds - Cards to fetch metadata for
|
||||
*/
|
||||
export function getMetadataProvider(
|
||||
opts?: GetMetadataOpts,
|
||||
): Cypress.Chainable<Lib.MetadataProvider> {
|
||||
return (
|
||||
cy
|
||||
// we need to log in before we can get metadata
|
||||
.getCookie("metabase.SESSION_ID")
|
||||
.then(() => _getMetadataProvider({ ...opts }))
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function which calls getMetadataProvider from ./metadata-provider
|
||||
// This module needs to imported lazily not to crash Cypress and to avoid importing
|
||||
// cljs in tests that don't need it.
|
||||
async function _getMetadataProvider(
|
||||
opts?: GetMetadataOpts,
|
||||
): Promise<Lib.MetadataProvider> {
|
||||
const { getMetadataProvider } = await import("./metadata-provider");
|
||||
return getMetadataProvider(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an MBQL query using a custom DSL that simplifies field selection and creation.
|
||||
*
|
||||
* @param metadataProvider - A Lib.MetadataProvider instance or a GetMetadataOpts object that will be user to instantiate a Lib.MetadataProvider
|
||||
* @param opts - The details of the query to create
|
||||
*/
|
||||
export function createQuery(
|
||||
metadataProvider: Lib.MetadataProvider | GetMetadataOpts,
|
||||
opts: CreateTestQueryOpts,
|
||||
): Cypress.Chainable<OpaqueDatasetQuery> {
|
||||
return ensureProvider(metadataProvider).then((provider) => {
|
||||
return createTestJsQuery(provider, opts);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function which calls createTestJsQuery from metabase-lib/test-helpers.
|
||||
// This module needs to imported lazily not to crash Cypress and to avoid importing
|
||||
// cljs in tests that don't need it.
|
||||
async function createTestJsQuery(
|
||||
metadataProvider: Lib.MetadataProvider,
|
||||
opts: CreateTestQueryOpts,
|
||||
): Promise<OpaqueDatasetQuery> {
|
||||
const { createTestJsQuery } = await import("metabase-lib/test-helpers");
|
||||
return createTestJsQuery(metadataProvider, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a native MBQL query using a custom DSL that simplifies field selection and creation.
|
||||
*
|
||||
* @param metadataProvider - A Lib.MetadataProvider instance or a GetMetadataOpts object that will be user to instantiate a Lib.MetadataProvider
|
||||
* @param databaseId - The database the query is for
|
||||
* @param query - The native query string
|
||||
*/
|
||||
export function createNativeQuery(
|
||||
metadataProvider: Lib.MetadataProvider | GetMetadataOpts,
|
||||
databaseId: DatabaseId,
|
||||
query: string,
|
||||
): Cypress.Chainable<OpaqueDatasetQuery> {
|
||||
return ensureProvider(metadataProvider).then((provider) =>
|
||||
createTestNativeJsQuery(provider, databaseId, query),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function which calls createTestNativeJsQuery from metabase-lib/test-helpers.
|
||||
// This module needs to imported lazily not to crash Cypress and to avoid importing
|
||||
// cljs in tests that don't need it.
|
||||
async function createTestNativeJsQuery(
|
||||
metadataProvider: Lib.MetadataProvider,
|
||||
databaseId: DatabaseId,
|
||||
query: string,
|
||||
) {
|
||||
const { createTestNativeJsQuery } = await import("metabase-lib/test-helpers");
|
||||
return createTestNativeJsQuery(metadataProvider, databaseId, query);
|
||||
}
|
||||
|
||||
function ensureProvider(
|
||||
providerOrOpts: GetMetadataOpts | Lib.MetadataProvider,
|
||||
): Cypress.Chainable<Lib.MetadataProvider> {
|
||||
return isMetadataProvider(providerOrOpts)
|
||||
? cy.wrap(providerOrOpts)
|
||||
: getMetadataProvider(providerOrOpts);
|
||||
}
|
||||
|
||||
function isMetadataProvider(
|
||||
opts: GetMetadataOpts | Lib.MetadataProvider,
|
||||
): opts is Lib.MetadataProvider {
|
||||
return "cache" in opts;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,7 @@ describe("scenarios > admin > people", () => {
|
||||
|
||||
cy.findByRole("link", { name: /group/i }).click();
|
||||
|
||||
cy.findByRole("table").findByRole("link", { name: /data/i }).click();
|
||||
cy.findByRole("table").findByRole("link", { name: /data$/i }).click();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("1 member");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
|
||||
describe("issue 26470", { tags: "@external" }, () => {
|
||||
beforeEach(() => {
|
||||
H.restore("postgres_12");
|
||||
H.restore("postgres-writable");
|
||||
cy.signInAsAdmin();
|
||||
cy.request("POST", "/api/persist/enable");
|
||||
});
|
||||
|
||||
@@ -139,17 +139,14 @@ describe("issue 14636", () => {
|
||||
"have.value",
|
||||
"field values scanning",
|
||||
);
|
||||
cy.findByPlaceholderText("Filter by status").should(
|
||||
"have.value",
|
||||
"Success",
|
||||
);
|
||||
getFilterByStatus().should("have.value", "Success");
|
||||
cy.findAllByTestId("task").should("have.length", 1);
|
||||
cy.findByTestId("task")
|
||||
.should("contain.text", "field values scanning")
|
||||
.and("contain.text", "Sample Database")
|
||||
.and("contain.text", "Success");
|
||||
|
||||
cy.findByPlaceholderText("Filter by status").click();
|
||||
getFilterByStatus().click();
|
||||
H.popover().findByText("Failed").click();
|
||||
cy.location("search").should(
|
||||
"eq",
|
||||
@@ -161,12 +158,9 @@ describe("issue 14636", () => {
|
||||
"No results",
|
||||
);
|
||||
|
||||
cy.findByPlaceholderText("Filter by status")
|
||||
.parent()
|
||||
.findByLabelText("Clear")
|
||||
.click();
|
||||
getFilterByStatus().parent().findByLabelText("Clear").click();
|
||||
cy.location("search").should("eq", "?task=field+values+scanning");
|
||||
cy.findByPlaceholderText("Filter by status").should("have.value", "");
|
||||
getFilterByStatus().should("have.value", "");
|
||||
cy.findAllByTestId("task").should("have.length", 1);
|
||||
cy.findByTestId("task")
|
||||
.should("contain.text", "field values scanning")
|
||||
@@ -184,14 +178,14 @@ describe("issue 14636", () => {
|
||||
|
||||
cy.log("should reset pagination when changing filters");
|
||||
cy.visit("/admin/tools/tasks/list?page=1");
|
||||
cy.findByPlaceholderText("Filter by status").click();
|
||||
getFilterByStatus().click();
|
||||
H.popover().findByText("Success").click();
|
||||
cy.location("search").should("eq", "?status=success");
|
||||
|
||||
cy.log("should remove invalid query params");
|
||||
cy.visit("/admin/tools/tasks/list?status=foobar");
|
||||
cy.location("search").should("eq", "");
|
||||
cy.findByPlaceholderText("Filter by status").should("have.value", "");
|
||||
getFilterByStatus().should("have.value", "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -832,54 +826,40 @@ describe("scenarios > admin > tools > task runs filtering", () => {
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.log("Filter by run type");
|
||||
cy.findByPlaceholderText("Filter by run type").click();
|
||||
getFilterByRun().click();
|
||||
H.popover().findByText("Sync").click();
|
||||
cy.location("search").should("contain", "run-type=sync");
|
||||
cy.wait("@getTaskRuns");
|
||||
cy.log("Filter by started at");
|
||||
getFilterByStartedAt().click();
|
||||
H.popover().findByText("Previous 30 days").click();
|
||||
cy.location("search").should("contain", "started-at=past30days");
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.wait("@getEntities");
|
||||
cy.log("Filter by entity");
|
||||
cy.findByPlaceholderText("Filter by entity").click();
|
||||
getFilterByEntity().click();
|
||||
H.popover().findByText("Sample Database").click();
|
||||
cy.location("search").should("contain", "entity-type=database");
|
||||
cy.location("search").should("contain", "entity-id=1");
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.log("Filter by status");
|
||||
cy.findByPlaceholderText("Filter by status").click();
|
||||
getFilterByStatus().click();
|
||||
H.popover().findByText("Success").click();
|
||||
cy.location("search").should("contain", "status=success");
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.log("Clear all filters");
|
||||
cy.findByPlaceholderText("Filter by run type")
|
||||
.parent()
|
||||
.findByLabelText("Clear")
|
||||
.click();
|
||||
cy.findByPlaceholderText("Filter by status")
|
||||
.parent()
|
||||
.findByLabelText("Clear")
|
||||
.click();
|
||||
getFilterByRun().parent().findByLabelText("Clear").click();
|
||||
getFilterByStartedAt().parent().findByLabelText("Clear").click();
|
||||
getFilterByStatus().parent().findByLabelText("Clear").click();
|
||||
cy.location("search").should("eq", "");
|
||||
});
|
||||
|
||||
it("entity picker should be disabled/enabled based on run type and entities availability", () => {
|
||||
it("entity picker should be disabled/enabled based on run type, started at and entities availability", () => {
|
||||
cy.visit("/admin/tools/tasks/runs");
|
||||
cy.wait("@getTaskRuns");
|
||||
|
||||
cy.log("Should be disabled when no run type is selected");
|
||||
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
|
||||
|
||||
cy.log("Should show tooltip 'Select a run type first' when hovering");
|
||||
cy.findByPlaceholderText("Filter by entity").trigger("mouseenter", {
|
||||
force: true,
|
||||
});
|
||||
cy.findByRole("tooltip").should("have.text", "Select a run type first");
|
||||
cy.findByPlaceholderText("Filter by entity").trigger("mouseleave", {
|
||||
force: true,
|
||||
});
|
||||
|
||||
cy.log("Should show loader while loading entities");
|
||||
cy.intercept("GET", "/api/task/runs/entities?*", {
|
||||
body: [
|
||||
{
|
||||
@@ -891,10 +871,24 @@ describe("scenarios > admin > tools > task runs filtering", () => {
|
||||
delay: 500,
|
||||
}).as("getEntitiesDelayed");
|
||||
|
||||
cy.findByPlaceholderText("Filter by run type").click();
|
||||
cy.log("Should be disabled when no run type is selected");
|
||||
getFilterByEntity().should("be.disabled");
|
||||
assertFilterByEntityTooltipText("Select a run type first");
|
||||
|
||||
getFilterByRun().click();
|
||||
H.popover().findByText("Sync").click();
|
||||
|
||||
cy.findByPlaceholderText("Filter by entity")
|
||||
cy.log("Should be still disabled until started at is selected");
|
||||
getFilterByEntity().should("be.disabled");
|
||||
|
||||
cy.log("Should show tooltip 'Select a start time' when hovering");
|
||||
assertFilterByEntityTooltipText("Select a start time first");
|
||||
|
||||
getFilterByStartedAt().click();
|
||||
H.popover().findByText("Previous 30 days").click();
|
||||
|
||||
cy.log("Should show loader while loading entities");
|
||||
getFilterByEntity()
|
||||
.should("be.disabled")
|
||||
.closest(".mb-mantine-Select-root")
|
||||
.find(".mb-mantine-Loader-root")
|
||||
@@ -903,30 +897,56 @@ describe("scenarios > admin > tools > task runs filtering", () => {
|
||||
cy.wait("@getEntitiesDelayed");
|
||||
|
||||
cy.log("Should be enabled after entities are loaded");
|
||||
cy.findByPlaceholderText("Filter by entity").should("not.be.disabled");
|
||||
getFilterByEntity().should("not.be.disabled");
|
||||
|
||||
cy.log("Should clear and disable entity filter when run type is cleared");
|
||||
cy.findByPlaceholderText("Filter by run type")
|
||||
.parent()
|
||||
.findByLabelText("Clear")
|
||||
.click();
|
||||
getFilterByRun().parent().findByLabelText("Clear").click();
|
||||
|
||||
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
|
||||
cy.findByPlaceholderText("Filter by entity").should("have.value", "");
|
||||
getFilterByEntity().should("be.disabled");
|
||||
getFilterByEntity().should("have.value", "");
|
||||
|
||||
cy.log("Should clear and disable entity filter when started at is cleared");
|
||||
getFilterByRun().click();
|
||||
H.popover().findByText("Sync").click();
|
||||
getFilterByStartedAt().parent().findByLabelText("Clear").click();
|
||||
|
||||
getFilterByEntity().should("be.disabled");
|
||||
getFilterByEntity().should("have.value", "");
|
||||
|
||||
cy.log("Should show tooltip 'No entities available' when no entities");
|
||||
cy.intercept("GET", "/api/task/runs/entities?*", {
|
||||
body: [],
|
||||
}).as("getEmptyEntities");
|
||||
|
||||
cy.findByPlaceholderText("Filter by run type").click();
|
||||
getFilterByRun().click();
|
||||
H.popover().findByText("Alert").click();
|
||||
getFilterByStartedAt().click();
|
||||
H.popover().findByText("Previous 30 days").click();
|
||||
cy.wait("@getEmptyEntities");
|
||||
|
||||
cy.findByPlaceholderText("Filter by entity").should("be.disabled");
|
||||
cy.findByPlaceholderText("Filter by entity").trigger("mouseenter", {
|
||||
force: true,
|
||||
});
|
||||
cy.findByRole("tooltip").should("have.text", "No entities available");
|
||||
getFilterByEntity().should("be.disabled");
|
||||
assertFilterByEntityTooltipText("No entities available");
|
||||
});
|
||||
});
|
||||
|
||||
function getFilterByRun() {
|
||||
return cy.findByPlaceholderText("Filter by run type");
|
||||
}
|
||||
function getFilterByStartedAt() {
|
||||
return cy.findByPlaceholderText("Filter by started at");
|
||||
}
|
||||
|
||||
function getFilterByEntity() {
|
||||
return cy.findByPlaceholderText("Filter by entity");
|
||||
}
|
||||
|
||||
function getFilterByStatus() {
|
||||
return cy.findByPlaceholderText("Filter by status");
|
||||
}
|
||||
|
||||
function assertFilterByEntityTooltipText(text: string) {
|
||||
getFilterByEntity().trigger("mouseenter", {
|
||||
force: true,
|
||||
});
|
||||
cy.findByRole("tooltip").should("have.text", text);
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ describe("issue 56567", () => {
|
||||
["All Users", allUsersPermission],
|
||||
["collection", "Curate"],
|
||||
["data", "No access"],
|
||||
["Data Analysts", "No access"],
|
||||
["nosql", "No access"],
|
||||
["readonly", "View"],
|
||||
];
|
||||
|
||||
@@ -1525,6 +1525,114 @@ SELECT 'group_2', 'sub_group_2', 52, 'group_2__sub_group_2';
|
||||
const scalarContainer = () => cy.findByTestId("scalar-container");
|
||||
const previousValue = () => cy.findByTestId("scalar-previous-value");
|
||||
|
||||
describe("issue 67432", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should copy sorted table data in correct sorted order (metabase#67432)", () => {
|
||||
H.grantClipboardPermissions();
|
||||
|
||||
const ROWS_LIMIT = 5;
|
||||
const questionDetails = {
|
||||
name: "67432 Question",
|
||||
query: {
|
||||
"source-table": PRODUCTS_ID,
|
||||
fields: [
|
||||
["field", PRODUCTS.ID, null],
|
||||
["field", PRODUCTS.TITLE, null],
|
||||
["field", PRODUCTS.CATEGORY, null],
|
||||
],
|
||||
limit: ROWS_LIMIT,
|
||||
},
|
||||
};
|
||||
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails,
|
||||
cardDetails: {
|
||||
size_x: 16,
|
||||
size_y: 10,
|
||||
},
|
||||
}).then(({ body: { dashboard_id } }) => {
|
||||
H.visitDashboard(dashboard_id);
|
||||
});
|
||||
|
||||
// Wait for table to load
|
||||
H.tableInteractiveBody().should("be.visible");
|
||||
|
||||
// Sort by Category column (descending first click)
|
||||
H.tableHeaderClick("Category");
|
||||
|
||||
// Wait for sort to apply - the sort icon should appear
|
||||
H.tableHeaderColumn("Category")
|
||||
.closest("[data-testid=header-cell]")
|
||||
.icon("chevrondown")
|
||||
.should("exist");
|
||||
|
||||
// Collect the visual order of categories from the table
|
||||
const visualCategories = [];
|
||||
H.tableInteractiveBody()
|
||||
.find('[data-column-id="CATEGORY"]')
|
||||
.each(($cell) => {
|
||||
visualCategories.push($cell.text());
|
||||
})
|
||||
.then(() => {
|
||||
// Select multiple cells across rows by dragging
|
||||
const getNonPKCells = () =>
|
||||
H.tableInteractiveBody().find(
|
||||
'[data-selectable-cell]:not([data-column-id="ID"])',
|
||||
);
|
||||
|
||||
// Select cells in first two rows (4 cells: Title+Category for 2 rows)
|
||||
getNonPKCells()
|
||||
.eq(0)
|
||||
.trigger("mousedown", { which: 1 })
|
||||
.then(() => {
|
||||
const lastCellIndex = ROWS_LIMIT * 2 - 1;
|
||||
getNonPKCells()
|
||||
.should("have.length", ROWS_LIMIT * 2)
|
||||
.eq(lastCellIndex)
|
||||
.trigger("mouseover", { buttons: 1 });
|
||||
getNonPKCells()
|
||||
.should("have.length", ROWS_LIMIT * 2)
|
||||
.eq(lastCellIndex)
|
||||
.trigger("mouseup");
|
||||
});
|
||||
|
||||
// Copy to clipboard
|
||||
cy.realPress(["Meta", "c"]);
|
||||
|
||||
// Verify clipboard content has rows in sorted order
|
||||
H.readClipboard().then((clipboardText) => {
|
||||
// The clipboard should contain properly tab-separated content
|
||||
// with newlines between rows (not a single cell)
|
||||
const lines = clipboardText.split("\n");
|
||||
|
||||
// Should have header row + data rows (at least 6 lines: header + 5 data rows)
|
||||
expect(lines.length).to.be.eq(ROWS_LIMIT + 1);
|
||||
|
||||
// Header should be tab-separated with both columns
|
||||
const headerCells = lines[0].split("\t");
|
||||
expect(headerCells).to.include("Title");
|
||||
expect(headerCells).to.include("Category");
|
||||
|
||||
// Verify each data row is tab-separated and in the correct sorted order
|
||||
const clipboardCategories = lines.slice(1).map((line) => {
|
||||
const cells = line.split("\t");
|
||||
// Category is the second column
|
||||
return cells[1];
|
||||
});
|
||||
|
||||
// The categories in clipboard should match the visual order
|
||||
for (let i = 0; i < clipboardCategories.length; i++) {
|
||||
expect(clipboardCategories[i]).to.equal(visualCategories[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 63416", () => {
|
||||
const questionDetails = {
|
||||
name: "63416 Question",
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Table {
|
||||
describe("bulk table operations", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
H.resetSnowplow();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.intercept("POST", "/api/ee/data-studio/table/sync-schema").as(
|
||||
@@ -63,6 +64,9 @@ describe("bulk table operations", () => {
|
||||
cy.findByRole("heading", { name: /2 tables selected/ });
|
||||
|
||||
cy.findByRole("button", { name: /Sync settings/ }).click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_sync_settings_clicked",
|
||||
});
|
||||
cy.findByRole("button", { name: /Sync table schemas/ }).click();
|
||||
cy.findByRole("button", { name: /Sync triggered!/ }).should("be.visible");
|
||||
cy.get<number[]>("@tableIds").then((tableIds) => {
|
||||
@@ -73,6 +77,10 @@ describe("bulk table operations", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_schema_sync_started",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
cy.findByRole("button", { name: /Re-scan tables/ }).click();
|
||||
cy.findByRole("button", { name: /Scan triggered!/ }).should("be.visible");
|
||||
@@ -85,6 +93,10 @@ describe("bulk table operations", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_fields_rescan_started",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
cy.findByRole("button", { name: /Discard cached field values/ }).click();
|
||||
cy.findByRole("button", { name: /Discard triggered!/ }).should(
|
||||
@@ -98,6 +110,10 @@ describe("bulk table operations", () => {
|
||||
expect(response?.statusCode).to.eq(204);
|
||||
});
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_field_values_discard_started",
|
||||
result: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it(
|
||||
@@ -134,6 +150,9 @@ describe("bulk table operations", () => {
|
||||
cy.findByRole("button", { name: /Unpublish/ }).click();
|
||||
H.modal().findByText("Unpublish these tables").click();
|
||||
cy.wait("@unpublishTables");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_unpublished",
|
||||
});
|
||||
H.DataStudio.nav().findByLabelText("Library").click();
|
||||
|
||||
H.DataStudio.Library.libraryPage().within(() => {
|
||||
@@ -155,15 +174,35 @@ describe("bulk table operations", () => {
|
||||
|
||||
H.selectHasValue("Owner", "").click();
|
||||
H.selectDropdown().contains("Bobby Tables").click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_attribute_updated",
|
||||
event_detail: "owner",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
H.selectHasValue("Visibility type", "").click();
|
||||
H.selectDropdown().contains("Gold").click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_attribute_updated",
|
||||
event_detail: "layer",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
H.selectHasValue("Entity type", "").click();
|
||||
H.selectDropdown().contains("Person").click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_attribute_updated",
|
||||
event_detail: "entity_type",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
H.selectHasValue("Source", "").click();
|
||||
H.selectDropdown().contains("Ingested").click();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_bulk_attribute_updated",
|
||||
event_detail: "data_source",
|
||||
result: "success",
|
||||
});
|
||||
H.undoToastList().should("have.length", 4);
|
||||
TablePicker.getTable("Orders")
|
||||
.findByTestId("table-owner")
|
||||
|
||||
@@ -7,6 +7,7 @@ describe("Search", () => {
|
||||
beforeEach(() => {
|
||||
cy.signInAsAdmin();
|
||||
H.restore("postgres-writable");
|
||||
H.resetSnowplow();
|
||||
H.activateToken("bleeding-edge");
|
||||
H.resetTestTable({ type: "postgres", table: "multi_schema" });
|
||||
H.resyncDatabase({ dbId: WRITABLE_DB_ID });
|
||||
@@ -19,6 +20,9 @@ describe("Search", () => {
|
||||
TablePicker.getTables().should("have.length", 3);
|
||||
TablePicker.getTable("Analytic Events").should("be.visible");
|
||||
TablePicker.getTable("Animals").should("be.visible");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_picker_search_performed",
|
||||
});
|
||||
});
|
||||
|
||||
it("should support wildcard search with *", () => {
|
||||
@@ -26,10 +30,19 @@ describe("Search", () => {
|
||||
|
||||
TablePicker.getSearchInput().type("irds");
|
||||
TablePicker.get().findByText("No tables found").should("be.visible");
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_picker_search_performed",
|
||||
});
|
||||
|
||||
TablePicker.getSearchInput().clear().type("*irds");
|
||||
TablePicker.getTables().should("have.length", 1);
|
||||
TablePicker.getTable("Birds").should("be.visible");
|
||||
H.expectUnstructuredSnowplowEvent(
|
||||
{
|
||||
event: "data_studio_table_picker_search_performed",
|
||||
},
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow using shift key to select multiple tables", () => {
|
||||
@@ -105,6 +118,8 @@ describe("Search", () => {
|
||||
it("should select/deselect databases and schemas", () => {
|
||||
H.DataModel.visitDataStudio();
|
||||
TablePicker.getSearchInput().type("a");
|
||||
// wait for the tables to be loaded
|
||||
TablePicker.getTables().should("have.length", 4);
|
||||
const postgres = "Writable Postgres12";
|
||||
const sampleDatabaseName = "Sample Database";
|
||||
const domesticSchema = "Domestic";
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import {
|
||||
SAMPLE_DB_ID,
|
||||
SAMPLE_DB_SCHEMA_ID,
|
||||
USER_GROUPS,
|
||||
WRITABLE_DB_ID,
|
||||
} from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import { DataModel } from "e2e/support/helpers";
|
||||
import {
|
||||
NODATA_USER_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
import type { TableId } from "metabase-types/api";
|
||||
|
||||
const { H } = cy;
|
||||
const { TablePicker, TableSection, FieldSection, PreviewSection } = H.DataModel;
|
||||
|
||||
const { ALL_USERS_GROUP } = USER_GROUPS;
|
||||
const {
|
||||
FEEDBACK,
|
||||
FEEDBACK_ID,
|
||||
ORDERS,
|
||||
ORDERS_ID,
|
||||
PEOPLE_ID,
|
||||
PRODUCTS,
|
||||
PRODUCTS_ID,
|
||||
REVIEWS,
|
||||
@@ -27,12 +26,10 @@ const {
|
||||
const MYSQL_DB_ID = SAMPLE_DB_ID + 1;
|
||||
const MYSQL_DB_SCHEMA_ID = `${MYSQL_DB_ID}:`;
|
||||
|
||||
const CUSTOM_MAPPING_ERROR =
|
||||
"You need unrestricted data access on this table to map custom display values.";
|
||||
|
||||
describe("scenarios > data studio > datamodel", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
H.resetSnowplow();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
|
||||
@@ -574,6 +571,12 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
openFilterPopover();
|
||||
selectFilterOption("Visibility type", "Gold");
|
||||
applyFilters();
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_picker_filters_applied",
|
||||
});
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "data_studio_table_picker_search_performed",
|
||||
});
|
||||
|
||||
cy.get<TableId>("@goldTableId").then(expectTableVisible);
|
||||
cy.get<TableId>("@silverTableId").then(expectTableNotVisible);
|
||||
@@ -1064,31 +1067,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
cy.findByText("New description").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow changing the table name with data model permissions only", () => {
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
});
|
||||
|
||||
TableSection.getNameInput().clear().type("New orders").blur();
|
||||
cy.wait("@updateTable");
|
||||
verifyAndCloseToast("Table name updated");
|
||||
TableSection.getNameInput().should("have.value", "New orders");
|
||||
cy.signOut();
|
||||
|
||||
cy.signInAsNormalUser();
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("People").should("be.visible");
|
||||
cy.findByText("New orders").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow clearing the table description", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -1107,6 +1085,76 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("No description yet").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow analysts to edit all table metadata even without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
|
||||
cy.signIn("nodata");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
});
|
||||
|
||||
cy.log("change table name");
|
||||
TableSection.getNameInput().clear().type("Analyst Orders").blur();
|
||||
cy.wait("@updateTable");
|
||||
verifyAndCloseToast("Table name updated");
|
||||
TableSection.getNameInput().should("have.value", "Analyst Orders");
|
||||
|
||||
cy.log("change table description");
|
||||
TableSection.getDescriptionInput()
|
||||
.clear()
|
||||
.type("Description by analyst")
|
||||
.blur();
|
||||
cy.wait("@updateTable");
|
||||
verifyAndCloseToast("Table description updated");
|
||||
TableSection.getDescriptionInput().should(
|
||||
"have.value",
|
||||
"Description by analyst",
|
||||
);
|
||||
|
||||
cy.log("change field name");
|
||||
TableSection.getFieldNameInput("Tax")
|
||||
.clear()
|
||||
.type("Analyst Tax")
|
||||
.blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Name of Tax updated");
|
||||
TableSection.getFieldNameInput("Analyst Tax").should("be.visible");
|
||||
|
||||
cy.log("change field description");
|
||||
TableSection.getFieldDescriptionInput("Total")
|
||||
.clear()
|
||||
.type("Total edited by analyst")
|
||||
.blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Description of Total updated");
|
||||
|
||||
cy.log("verify changes in data reference as admin");
|
||||
cy.signInAsAdmin();
|
||||
cy.visit(`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}`);
|
||||
cy.get("main").within(() => {
|
||||
cy.findByText("Analyst Orders").should("be.visible");
|
||||
cy.findByText("Description by analyst").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("verify changes in question picker as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.startNewQuestion();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("People").should("be.visible");
|
||||
cy.findByText("Analyst Orders").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("verify field changes in table visualization");
|
||||
H.openOrdersTable();
|
||||
H.tableHeaderColumn("Analyst Tax").should("be.visible");
|
||||
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
|
||||
"not.exist",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field name and description", () => {
|
||||
@@ -1139,43 +1187,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow changing the field name with data model permissions only", () => {
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
});
|
||||
|
||||
TableSection.getFieldNameInput("Tax").clear().type("New tax").blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Name of Tax updated");
|
||||
TableSection.getFieldNameInput("New tax").should("be.visible");
|
||||
TableSection.getField("New tax").should("be.visible");
|
||||
|
||||
cy.log("verify preview");
|
||||
TableSection.clickField("New tax");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify viz as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openOrdersTable();
|
||||
H.tableHeaderColumn("New tax").should("be.visible");
|
||||
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
|
||||
"not.exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow changing the field description", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -1242,6 +1253,83 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("No description yet").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow analysts to edit field metadata but not preview data without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
});
|
||||
|
||||
cy.log("change field name from table section");
|
||||
TableSection.getFieldNameInput("Tax")
|
||||
.clear()
|
||||
.type("Analyst Tax Field")
|
||||
.blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Name of Tax updated");
|
||||
TableSection.getFieldNameInput("Analyst Tax Field").should(
|
||||
"be.visible",
|
||||
);
|
||||
TableSection.getField("Analyst Tax Field").should("be.visible");
|
||||
|
||||
cy.log("change field description from table section");
|
||||
TableSection.getFieldDescriptionInput("Total")
|
||||
.clear()
|
||||
.type("Analyst total description")
|
||||
.blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Description of Total updated");
|
||||
TableSection.getFieldDescriptionInput("Total").should(
|
||||
"have.value",
|
||||
"Analyst total description",
|
||||
);
|
||||
|
||||
cy.log("navigate to field detail and change semantic type");
|
||||
TableSection.clickField("Discount");
|
||||
FieldSection.getSemanticTypeInput()
|
||||
.should("have.value", "Discount")
|
||||
.click();
|
||||
H.popover().findByText("Currency").click();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Semantic type of Discount updated");
|
||||
FieldSection.getSemanticTypeInput().should("have.value", "Currency");
|
||||
|
||||
cy.log("verify table preview is blocked without data permissions");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify detail preview is also blocked");
|
||||
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify field changes in data reference as admin");
|
||||
cy.signInAsAdmin();
|
||||
cy.visit(
|
||||
`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/fields/${ORDERS.TOTAL}`,
|
||||
);
|
||||
cy.get("main").within(() => {
|
||||
cy.findByText("Total").should("be.visible");
|
||||
cy.findByText("Analyst total description").should("be.visible");
|
||||
});
|
||||
|
||||
cy.log("verify field changes in table visualization as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openOrdersTable();
|
||||
H.tableHeaderColumn("Analyst Tax Field").should("be.visible");
|
||||
H.tableHeaderColumn("Tax", { scrollIntoView: false }).should(
|
||||
"not.exist",
|
||||
);
|
||||
H.tableHeaderColumn("Discount ($)").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sorting", () => {
|
||||
@@ -1534,46 +1622,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow changing the field name with data model permissions only", () => {
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: ORDERS.TOTAL,
|
||||
});
|
||||
|
||||
FieldSection.getNameInput().clear().type("New total").blur();
|
||||
cy.wait("@updateField");
|
||||
verifyAndCloseToast("Name of Total updated");
|
||||
FieldSection.getNameInput().should("have.value", "New total");
|
||||
TableSection.getFieldNameInput("New total")
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify preview");
|
||||
TableSection.clickField("New total");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify viz as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openOrdersTable();
|
||||
H.tableHeaderColumn("New total").should("be.visible");
|
||||
H.tableHeaderColumn("Total", { scrollIntoView: false }).should(
|
||||
"not.exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow changing the field description", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -1981,84 +2029,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
cy.findByLabelText("Left column").should("contain.text", "User ID");
|
||||
});
|
||||
|
||||
it("should allow to change the field foreign key target with no permissions to Reviews table", () => {
|
||||
setDataModelPermissions({
|
||||
tableIds: [ORDERS_ID, PRODUCTS_ID, PEOPLE_ID],
|
||||
});
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: ORDERS.USER_ID,
|
||||
});
|
||||
FieldSection.getSemanticTypeFkTarget()
|
||||
.should("have.value", "People → ID")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Reviews → ID").should("not.exist");
|
||||
cy.findByText("Products → ID").click();
|
||||
});
|
||||
cy.wait("@updateField");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Semantic type of User ID updated",
|
||||
);
|
||||
FieldSection.getSemanticTypeFkTarget().should(
|
||||
"have.value",
|
||||
"Products → ID",
|
||||
);
|
||||
|
||||
cy.log("verify preview");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify viz as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openTable({
|
||||
database: SAMPLE_DB_ID,
|
||||
table: ORDERS_ID,
|
||||
mode: "notebook",
|
||||
});
|
||||
cy.icon("join_left_outer").click();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Products").click();
|
||||
});
|
||||
cy.findByLabelText("Left column").should("contain.text", "User ID");
|
||||
});
|
||||
|
||||
it("should not allow setting foreign key target for inaccessible tables", () => {
|
||||
setDataModelPermissions({ tableIds: [REVIEWS_ID] });
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.PRODUCT_ID,
|
||||
});
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("option", { name: /Use original value/ })
|
||||
.should("be.visible")
|
||||
.and("not.have.attr", "data-combobox-disabled");
|
||||
cy.findByRole("option", { name: /Use foreign key/ })
|
||||
.should("be.visible")
|
||||
.and("have.attr", "data-combobox-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to change the type to 'Currency' and choose the currency (metabase#59052)", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -2238,6 +2208,56 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow analysts to change the foreign key target without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: ORDERS_ID,
|
||||
fieldId: ORDERS.USER_ID,
|
||||
});
|
||||
|
||||
FieldSection.getSemanticTypeFkTarget()
|
||||
.should("have.value", "People → ID")
|
||||
.click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Reviews → ID").should("be.visible");
|
||||
cy.findByText("Products → ID").click();
|
||||
});
|
||||
cy.wait("@updateField");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Semantic type of User ID updated",
|
||||
);
|
||||
FieldSection.getSemanticTypeFkTarget().should(
|
||||
"have.value",
|
||||
"Products → ID",
|
||||
);
|
||||
|
||||
cy.log("verify preview is blocked without data permissions");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify FK target change works in query builder as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openTable({
|
||||
database: SAMPLE_DB_ID,
|
||||
table: ORDERS_ID,
|
||||
mode: "notebook",
|
||||
});
|
||||
cy.icon("join_left_outer").click();
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Products").click();
|
||||
});
|
||||
cy.findByLabelText("Left column").should("contain.text", "User ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Behavior", () => {
|
||||
@@ -2653,34 +2673,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
.and("have.value", "Title");
|
||||
});
|
||||
|
||||
it("should allow to change foreign key target for accessible tables", () => {
|
||||
setDataModelPermissions({
|
||||
tableIds: [ORDERS_ID, REVIEWS_ID, PRODUCTS_ID],
|
||||
});
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.PRODUCT_ID,
|
||||
});
|
||||
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().findByText("Use foreign key").click();
|
||||
H.popover().findByText("Title").click();
|
||||
cy.wait("@updateFieldDimension");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Product ID updated",
|
||||
);
|
||||
|
||||
cy.signInAsNormalUser();
|
||||
H.openReviewsTable({ limit: 1 });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Rustic Paper Wallet").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow 'Custom mapping' null values", () => {
|
||||
const remappedNullValue = "nothin";
|
||||
|
||||
@@ -2868,54 +2860,6 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should show a proper error message when using custom mapping", () => {
|
||||
setDataModelPermissions({ tableIds: [REVIEWS_ID] });
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("option", { name: /Use original value/ })
|
||||
.should("be.visible")
|
||||
.and("not.have.attr", "data-combobox-disabled");
|
||||
cy.findByRole("option", { name: /Custom mapping/ })
|
||||
.should("be.visible")
|
||||
.and("have.attr", "data-combobox-disabled", "true");
|
||||
});
|
||||
|
||||
cy.signInAsAdmin();
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().findByText("Custom mapping").click();
|
||||
cy.wait("@updateFieldDimension");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Rating updated",
|
||||
);
|
||||
|
||||
cy.signIn("none");
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(CUSTOM_MAPPING_ERROR).should("exist");
|
||||
});
|
||||
|
||||
it("should allow 'Custom mapping' option only for 'Search box' filtering type (metabase#16322)", () => {
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
@@ -3009,6 +2953,105 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
.eq(10) // 1st data row, 2nd column (User ID)
|
||||
.should("have.text", "2023-10-07T01:34:35.462-07:00");
|
||||
});
|
||||
|
||||
it("should allow analysts to change display values to use foreign key without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.PRODUCT_ID,
|
||||
});
|
||||
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().findByText("Use foreign key").click();
|
||||
H.popover().findByText("Title").click();
|
||||
cy.wait("@updateFieldDimension");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Product ID updated",
|
||||
);
|
||||
|
||||
FieldSection.getDisplayValuesInput().should(
|
||||
"have.value",
|
||||
"Use foreign key",
|
||||
);
|
||||
FieldSection.getDisplayValuesFkTargetInput().should(
|
||||
"have.value",
|
||||
"Title",
|
||||
);
|
||||
|
||||
cy.log("verify preview is blocked without data permissions");
|
||||
FieldSection.getPreviewButton().click();
|
||||
cy.wait("@dataset");
|
||||
PreviewSection.get()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify display value change works as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openReviewsTable({ limit: 1 });
|
||||
H.main().findByText("Rustic Paper Wallet").should("be.visible");
|
||||
});
|
||||
|
||||
it("should disable custom mapping for analysts without data access", () => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
|
||||
cy.log("verify custom mapping is disabled without data access");
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByRole("option", { name: /Use original value/ })
|
||||
.should("be.visible")
|
||||
.and("not.have.attr", "data-combobox-disabled");
|
||||
cy.findByRole("option", { name: /Custom mapping/ })
|
||||
.should("be.visible")
|
||||
.and("have.attr", "data-combobox-disabled", "true");
|
||||
});
|
||||
|
||||
cy.log("verify admin can set up custom mapping");
|
||||
cy.signInAsAdmin();
|
||||
H.DataModel.visitDataStudio({
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
schemaId: SAMPLE_DB_SCHEMA_ID,
|
||||
tableId: REVIEWS_ID,
|
||||
fieldId: REVIEWS.RATING,
|
||||
});
|
||||
FieldSection.getDisplayValuesInput().click();
|
||||
H.popover().findByText("Custom mapping").click();
|
||||
cy.wait("@updateFieldValues");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Rating updated",
|
||||
);
|
||||
H.undoToast().icon("close").click({ force: true });
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByDisplayValue("1").click().clear().type("Terrible");
|
||||
cy.findByDisplayValue("5").click().clear().type("Amazing");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
cy.wait("@updateFieldValues");
|
||||
H.undoToast().should(
|
||||
"contain.text",
|
||||
"Display values of Rating updated",
|
||||
);
|
||||
|
||||
cy.log("verify custom mapping works as normal user");
|
||||
cy.signInAsNormalUser();
|
||||
H.openReviewsTable();
|
||||
H.main().findByText("Terrible").should("be.visible");
|
||||
H.main().findAllByText("Amazing").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unfold JSON", { tags: "@external" }, () => {
|
||||
@@ -3480,7 +3523,7 @@ describe("scenarios > data studio > datamodel", () => {
|
||||
fieldId: ORDERS.PRODUCT_ID,
|
||||
});
|
||||
|
||||
DataModel.TableSection.getVisibilityTypeInput().click();
|
||||
H.DataModel.TableSection.getVisibilityTypeInput().click();
|
||||
H.popover().findByText("Copper").click();
|
||||
cy.wait("@updateTable");
|
||||
|
||||
@@ -3868,27 +3911,6 @@ function clickAway() {
|
||||
cy.get("body").click(0, 0);
|
||||
}
|
||||
|
||||
const setDataModelPermissions = ({
|
||||
tableIds = [],
|
||||
}: {
|
||||
tableIds: TableId[];
|
||||
}) => {
|
||||
const permissions = Object.fromEntries(tableIds.map((id) => [id, "all"]));
|
||||
|
||||
// @ts-expect-error invalid cy.updatePermissionsGraph typing
|
||||
cy.updatePermissionsGraph({
|
||||
[ALL_USERS_GROUP]: {
|
||||
[SAMPLE_DB_ID]: {
|
||||
"data-model": {
|
||||
schemas: {
|
||||
PUBLIC: permissions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type TableSummary = {
|
||||
id: TableId;
|
||||
db_id: number;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
SAMPLE_DB_ID,
|
||||
SAMPLE_DB_SCHEMA_ID,
|
||||
USER_GROUPS,
|
||||
} from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DB_ID, SAMPLE_DB_SCHEMA_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { NODATA_USER_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
const { H } = cy;
|
||||
const { SegmentList, SegmentEditor, SegmentRevisionHistory } = H.DataModel;
|
||||
const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, PEOPLE, PEOPLE_ID } =
|
||||
SAMPLE_DATABASE;
|
||||
const { ALL_USERS_GROUP } = USER_GROUPS;
|
||||
|
||||
describe(
|
||||
"scenarios > data studio > data model > segments",
|
||||
@@ -614,12 +610,12 @@ describe(
|
||||
});
|
||||
});
|
||||
|
||||
describe("Readonly access with data model permissions", () => {
|
||||
describe("Readonly access for data analysts", () => {
|
||||
it("should show segments in list but hide New segment button for non-admin", () => {
|
||||
createTestSegment({ name: "Readonly Test Segment" });
|
||||
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
cy.signIn("none");
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
cy.log("verify segment is visible in list");
|
||||
visitDataStudioSegments(ORDERS_ID);
|
||||
@@ -636,9 +632,7 @@ describe(
|
||||
cy.visit(
|
||||
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/segments/new`,
|
||||
);
|
||||
H.main()
|
||||
.findByText("Sorry, you don’t have permission to see that.")
|
||||
.should("be.visible");
|
||||
cy.url().should("include", "/unauthorized");
|
||||
});
|
||||
|
||||
it("should display segment detail in readonly mode for non-admin", () => {
|
||||
@@ -648,8 +642,8 @@ describe(
|
||||
});
|
||||
|
||||
cy.get<number>("@segmentId").then((segmentId) => {
|
||||
setDataModelPermissions({ tableIds: [ORDERS_ID] });
|
||||
cy.signIn("none");
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
visitDataModelSegment(ORDERS_ID, segmentId);
|
||||
|
||||
@@ -766,20 +760,3 @@ function verifySegmentNotInQueryBuilder(
|
||||
H.getNotebookStep("data").button("Filter").click();
|
||||
H.popover().findByText(segmentName).should("not.exist");
|
||||
}
|
||||
|
||||
function setDataModelPermissions({ tableIds = [] }: { tableIds: number[] }) {
|
||||
const permissions = Object.fromEntries(tableIds.map((id) => [id, "all"]));
|
||||
|
||||
// @ts-expect-error invalid cy.updatePermissionsGraph typing
|
||||
cy.updatePermissionsGraph({
|
||||
[ALL_USERS_GROUP]: {
|
||||
[SAMPLE_DB_ID]: {
|
||||
"data-model": {
|
||||
schemas: {
|
||||
PUBLIC: permissions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,14 +22,9 @@ describe("scenarios > data studio > library", () => {
|
||||
H.DataModel.visitDataStudio();
|
||||
H.DataStudio.nav().findByLabelText("Library").click();
|
||||
|
||||
cy.log("Closing the modal should send you back");
|
||||
H.modal().button("Cancel").click();
|
||||
H.DataModel.get().should("exist");
|
||||
|
||||
cy.log("Create library via modal");
|
||||
H.DataStudio.nav().findByLabelText("Library").click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Create your Library").should("be.visible");
|
||||
cy.log("Create library via inline empty state");
|
||||
H.DataStudio.Library.libraryPage().within(() => {
|
||||
cy.findByText("A source of truth for analytics").should("be.visible");
|
||||
cy.findByText("Create my Library").click();
|
||||
});
|
||||
|
||||
@@ -111,7 +106,7 @@ describe("scenarios > data studio > library", () => {
|
||||
|
||||
cy.log("Publish a table from the 'New' menu");
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Publish a table").click();
|
||||
H.popover().findByText("Published table").click();
|
||||
|
||||
cy.log("Select a table and click 'Publish'");
|
||||
H.entityPickerModalItem(3, "Orders").click();
|
||||
@@ -127,7 +122,7 @@ describe("scenarios > data studio > library", () => {
|
||||
"Verify tables in the entity picker are disabled if already published",
|
||||
);
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Publish a table").click();
|
||||
H.popover().findByText("Published table").click();
|
||||
H.entityPickerModalItem(3, "Orders").should("have.attr", "data-disabled");
|
||||
H.entityPickerModalItem(3, "People").should(
|
||||
"not.have.attr",
|
||||
@@ -198,7 +193,7 @@ describe("scenarios > data studio > library", () => {
|
||||
|
||||
cy.log("Publish a table via the +New menu");
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("Publish a table").click();
|
||||
H.popover().findByText("Published table").click();
|
||||
H.entityPickerModalItem(3, "Orders").click();
|
||||
H.entityPickerModal().button("Publish").click();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SAMPLE_DB_ID, SAMPLE_DB_SCHEMA_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { NODATA_USER_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
const { H } = cy;
|
||||
const { MeasureList, MeasureEditor, MeasureRevisionHistory } = H.DataModel;
|
||||
@@ -389,6 +390,76 @@ describe("scenarios > data studio > data model > measures", () => {
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Readonly access for data analysts", () => {
|
||||
it("should show measures in list but hide New measure button for non-admin", () => {
|
||||
createTestMeasure({ name: "Readonly Test Measure" });
|
||||
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
cy.log("verify measure is visible in list");
|
||||
visitDataStudioMeasures(ORDERS_ID);
|
||||
MeasureList.getMeasure("Readonly Test Measure")
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify New measure button is not visible");
|
||||
MeasureList.get()
|
||||
.findByRole("link", { name: /New measure/i })
|
||||
.should("not.exist");
|
||||
|
||||
cy.log("verify direct navigation to new measure page is blocked");
|
||||
cy.visit(
|
||||
`/data-studio/data/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/measures/new`,
|
||||
);
|
||||
cy.url().should("include", "/unauthorized");
|
||||
});
|
||||
|
||||
it("should display measure detail in readonly mode for non-admin", () => {
|
||||
createTestMeasure({
|
||||
name: "Readonly Detail Measure",
|
||||
description: "Test description for readonly",
|
||||
});
|
||||
|
||||
cy.get<number>("@measureId").then((measureId) => {
|
||||
H.setUserAsAnalyst(NODATA_USER_ID);
|
||||
cy.signIn("nodata");
|
||||
|
||||
visitDataModelMeasure(ORDERS_ID, measureId);
|
||||
|
||||
cy.log("verify measure name input is disabled");
|
||||
MeasureEditor.get()
|
||||
.findByDisplayValue("Readonly Detail Measure")
|
||||
.should("be.disabled");
|
||||
|
||||
cy.log("verify description is displayed as plain text");
|
||||
MeasureEditor.get().findByText("Description").should("be.visible");
|
||||
MeasureEditor.get()
|
||||
.findByText("Test description for readonly")
|
||||
.should("be.visible");
|
||||
|
||||
cy.log("verify Save button is not visible");
|
||||
MeasureEditor.get()
|
||||
.findByRole("button", { name: /Save/i })
|
||||
.should("not.exist");
|
||||
|
||||
cy.log("verify Remove measure option is hidden in actions menu");
|
||||
MeasureEditor.getActionsButton().click();
|
||||
H.popover().findByText("Preview").should("be.visible");
|
||||
H.popover().findByText("Remove measure").should("not.exist");
|
||||
cy.realPress("Escape");
|
||||
|
||||
cy.log("verify revision history is still accessible");
|
||||
MeasureEditor.getRevisionHistoryTab().click();
|
||||
MeasureRevisionHistory.get().within(() => {
|
||||
cy.findByText(/created this measure/i)
|
||||
.scrollIntoView()
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function visitDataStudioTable(tableId: number) {
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("scenarios > data studio > snippets", () => {
|
||||
H.DataStudio.Library.visit();
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("New snippet").click();
|
||||
H.popover().findByText("Snippet").click();
|
||||
|
||||
H.DataStudio.Snippets.newPage().should("be.visible");
|
||||
H.DataStudio.Snippets.saveButton().should("be.disabled");
|
||||
@@ -121,6 +121,42 @@ describe("scenarios > data studio > snippets", () => {
|
||||
|
||||
H.DataStudio.Snippets.editPage().should("be.visible");
|
||||
});
|
||||
|
||||
it("should preserve unsaved content changes when description or name is edited", () => {
|
||||
cy.log("Navigate to a snippet and edit its content");
|
||||
H.createSnippet({
|
||||
name: "Test snippet",
|
||||
content: "SELECT * FROM orders",
|
||||
});
|
||||
H.DataStudio.Library.visit();
|
||||
H.DataStudio.Library.libraryPage().findByText("Test snippet").click();
|
||||
H.DataStudio.Snippets.editor.type("1");
|
||||
|
||||
cy.log("Edit its name");
|
||||
cy.findByPlaceholderText("Name").type("1").blur();
|
||||
H.undoToast().findByText("Snippet name updated").should("be.visible");
|
||||
H.undoToast().icon("close").click();
|
||||
|
||||
cy.log("Edit its description");
|
||||
H.DataStudio.Snippets.descriptionInput().type("desc").blur();
|
||||
H.undoToast()
|
||||
.findByText("Snippet description updated")
|
||||
.should("be.visible");
|
||||
H.undoToast().icon("close").click();
|
||||
|
||||
cy.log("Verify unsaved changes are preserved");
|
||||
H.DataStudio.Snippets.editor
|
||||
.value()
|
||||
.should("eq", "SELECT * FROM orders1");
|
||||
|
||||
cy.log(
|
||||
"Verify Save button saves the content without reverting the name and description changes",
|
||||
);
|
||||
H.DataStudio.Snippets.saveButton().click();
|
||||
H.undoToast().findByText("Snippet content updated").should("be.visible");
|
||||
cy.findByPlaceholderText("Name").should("have.value", "Test snippet1");
|
||||
H.DataStudio.Snippets.editPage().findByText("desc").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("description", () => {
|
||||
@@ -178,7 +214,7 @@ describe("scenarios > data studio > snippets", () => {
|
||||
H.DataStudio.Library.visit();
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("New snippet folder").click();
|
||||
H.popover().findByText("Snippet folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Give your folder a name").type("Test Folder");
|
||||
@@ -194,7 +230,7 @@ describe("scenarios > data studio > snippets", () => {
|
||||
.should("be.visible");
|
||||
|
||||
H.DataStudio.Library.newButton().click();
|
||||
H.popover().findByText("New snippet").click();
|
||||
H.popover().findByText("Snippet").click();
|
||||
|
||||
H.DataStudio.Snippets.nameInput().clear().type("Folder snippet");
|
||||
H.DataStudio.Snippets.editor.type("SELECT 1");
|
||||
|
||||
@@ -2284,6 +2284,97 @@ LIMIT
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should navigate to the common library when clicking 'common' in an import statement",
|
||||
{ tags: ["@python"] },
|
||||
() => {
|
||||
visitTransformListPage();
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("Python script").click();
|
||||
cy.get(".cm-clickable-token").should("be.visible").click();
|
||||
H.modal().button("Discard changes").click();
|
||||
cy.url().should("include", "/data-studio/transforms/library/common.py");
|
||||
cy.findByTestId("python-library-header").should("be.visible");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should open the common library in a new tab when cmd-clicking 'common' in an import statement",
|
||||
{ tags: ["@python"] },
|
||||
() => {
|
||||
visitTransformListPage();
|
||||
cy.window().then((win) => {
|
||||
cy.stub(win, "open").as("windowOpen");
|
||||
});
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("Python script").click();
|
||||
cy.get(".cm-clickable-token")
|
||||
.should("be.visible")
|
||||
.click({ metaKey: true });
|
||||
|
||||
cy.get("@windowOpen").should(
|
||||
"have.been.calledWithMatch",
|
||||
"/data-studio/transforms/library/common.py",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"should be able to run a transform with default import common even without custom library code",
|
||||
{ tags: ["@python"] },
|
||||
() => {
|
||||
setPythonRunnerSettings();
|
||||
|
||||
visitTransformListPage();
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("Python script").click();
|
||||
|
||||
cy.log("import common should be included by default");
|
||||
H.PythonEditor.value().should("contain", "import common");
|
||||
|
||||
cy.log(
|
||||
"write a transform that imports common but does not use it - should still run",
|
||||
);
|
||||
H.PythonEditor.clear().type(
|
||||
dedent`
|
||||
import common
|
||||
import pandas as pd
|
||||
|
||||
def transform():
|
||||
return pd.DataFrame([{"result": 42}])
|
||||
`,
|
||||
{ allowFastSet: true },
|
||||
);
|
||||
|
||||
cy.findByTestId("python-data-picker")
|
||||
.findByText("Select a table…")
|
||||
.click();
|
||||
|
||||
H.entityPickerModal().within(() => {
|
||||
cy.findByText("Schema a").click();
|
||||
cy.findByText("Animals").click();
|
||||
});
|
||||
|
||||
getQueryEditor().button("Save").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").clear().type("Default common transform");
|
||||
cy.findByLabelText("Table name").clear().type("default_common");
|
||||
cy.button("Save").click();
|
||||
});
|
||||
|
||||
H.DataStudio.Transforms.runTab().click();
|
||||
runTransformAndWaitForSuccess();
|
||||
H.DataStudio.Transforms.settingsTab().click();
|
||||
getTableLink().click();
|
||||
H.queryBuilderHeader()
|
||||
.findByText("Default Common")
|
||||
.should("be.visible");
|
||||
H.assertQueryBuilderRowCount(1);
|
||||
cy.findByTestId("scalar-value").should("have.text", "42");
|
||||
},
|
||||
);
|
||||
|
||||
function visitCommonLibrary(path = "common.py") {
|
||||
cy.visit(`/data-studio/transforms/library/${path}`);
|
||||
}
|
||||
@@ -2298,7 +2389,7 @@ LIMIT
|
||||
cy.log("create a collection from the transforms list");
|
||||
visitTransformListPage();
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("New collection").click();
|
||||
H.popover().findByText("Transform folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("Marketing Transforms");
|
||||
@@ -2311,7 +2402,7 @@ LIMIT
|
||||
|
||||
cy.log("create a nested collection");
|
||||
cy.button("Create a transform").click();
|
||||
H.popover().findByText("New collection").click();
|
||||
H.popover().findByText("Transform folder").click();
|
||||
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Name").type("Q4 Reports");
|
||||
@@ -2738,6 +2829,85 @@ LIMIT
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("read-only remote sync", () => {
|
||||
beforeEach(() => {
|
||||
cy.log("create a transform");
|
||||
createSqlTransform({
|
||||
sourceQuery: `SELECT * FROM "${TARGET_SCHEMA}"."${SOURCE_TABLE}"`,
|
||||
});
|
||||
cy.log("set up remote sync");
|
||||
H.setupGitSync();
|
||||
H.configureGit("read-only");
|
||||
});
|
||||
|
||||
it("should make the transform list page read-only", () => {
|
||||
cy.log("visit transforms page");
|
||||
visitTransformListPage();
|
||||
|
||||
cy.log("'Create a transform' menu button is not displayed");
|
||||
cy.button("Create a transform").should("not.exist");
|
||||
|
||||
cy.log("clicking Python library navigates to the library editor");
|
||||
getTransformsList().findByText("Python library").click();
|
||||
|
||||
cy.log("python library editor is read-only");
|
||||
cy.url().should("include", "/data-studio/transforms/library/common.py");
|
||||
cy.findByRole("alert")
|
||||
.contains(/The Python library is not editable/)
|
||||
.should("be.visible");
|
||||
|
||||
H.DataStudio.PythonLibrary.editor().within(() => {
|
||||
cy.findByRole("textbox").should(
|
||||
"have.attr",
|
||||
"contenteditable",
|
||||
"false",
|
||||
);
|
||||
cy.findByRole("textbox").should("have.attr", "aria-readonly", "true");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow editing a transform", () => {
|
||||
cy.log("visit transform");
|
||||
cy.visit("/data-studio/transforms/1");
|
||||
|
||||
cy.log("'edit definition' button is not displayed");
|
||||
H.DataStudio.Transforms.editDefinition().should("not.exist");
|
||||
|
||||
cy.log("visit the Run tab");
|
||||
H.DataStudio.Transforms.runTab().click();
|
||||
|
||||
cy.log("schedule tags are not editable");
|
||||
cy.findByLabelText("Tags").should("be.visible");
|
||||
cy.findByLabelText("Tags").should("be.disabled");
|
||||
|
||||
cy.log("visit the Settings tab");
|
||||
H.DataStudio.Transforms.settingsTab().click();
|
||||
|
||||
cy.log("'Change target' button is not displayed");
|
||||
cy.findByRole("button", { name: /Change target/ }).should("not.exist");
|
||||
|
||||
cy.log("'Only process new and changed data' switch is not displayed");
|
||||
cy.findByRole("switch", {
|
||||
name: /Only process new and changed data/,
|
||||
}).should("be.disabled");
|
||||
|
||||
cy.log("visiting edit mode url directly redirects to view-only mode");
|
||||
cy.visit("/data-studio/transforms/1/edit");
|
||||
cy.url().should("not.include", "/edit");
|
||||
|
||||
H.DataStudio.Transforms.header()
|
||||
.findByRole("img", { name: "ellipsis icon" })
|
||||
.click();
|
||||
|
||||
cy.log("ellipsis menu does not have move or delete options");
|
||||
cy.findByRole("menu").within(() => {
|
||||
cy.findByRole("menuitem", { name: /History/ }).should("be.visible");
|
||||
cy.findByRole("menuitem", { name: /Move/ }).should("not.exist");
|
||||
cy.findByRole("menuitem", { name: /Delete/ }).should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scenarios > admin > transforms > databases without :schemas", () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
|
||||
describe("analysis", () => {
|
||||
it("should show broken dependencies", () => {
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
checkList({
|
||||
visibleEntities: BROKEN_DEPENDENCIES,
|
||||
hiddenEntities: BROKEN_DEPENDENTS,
|
||||
@@ -100,10 +100,10 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
|
||||
describe("sidebar", () => {
|
||||
it("should show broken dependents", () => {
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("table dependents");
|
||||
H.DataStudio.Tasks.list().findByText(TABLE_DISPLAY_NAME).click();
|
||||
H.DependencyDiagnostics.list().findByText(TABLE_DISPLAY_NAME).click();
|
||||
checkSidebar({
|
||||
entityName: TABLE_DISPLAY_NAME,
|
||||
transformName: TABLE_TRANSFORM,
|
||||
@@ -112,7 +112,7 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
});
|
||||
|
||||
cy.log("question dependents");
|
||||
H.DataStudio.Tasks.list().findByText(TABLE_BASED_QUESTION).click();
|
||||
H.DependencyDiagnostics.list().findByText(TABLE_BASED_QUESTION).click();
|
||||
checkSidebar({
|
||||
entityName: TABLE_BASED_QUESTION,
|
||||
missingColumns: ["PRICE"],
|
||||
@@ -120,7 +120,7 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
});
|
||||
|
||||
cy.log("model dependents");
|
||||
H.DataStudio.Tasks.list().findByText(TABLE_BASED_MODEL).click();
|
||||
H.DependencyDiagnostics.list().findByText(TABLE_BASED_MODEL).click();
|
||||
checkSidebar({
|
||||
entityName: TABLE_BASED_MODEL,
|
||||
missingColumns: ["AMOUNT"],
|
||||
@@ -131,8 +131,8 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
|
||||
describe("search", () => {
|
||||
it("should search for entities", () => {
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.searchInput().type(TABLE_DISPLAY_NAME);
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
H.DependencyDiagnostics.searchInput().type(TABLE_DISPLAY_NAME);
|
||||
checkList({
|
||||
visibleEntities: [TABLE_DISPLAY_NAME],
|
||||
hiddenEntities: [TABLE_BASED_QUESTION, TABLE_BASED_MODEL],
|
||||
@@ -142,8 +142,8 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
|
||||
describe("filtering", () => {
|
||||
it("should filter entities by type", () => {
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Table").click();
|
||||
cy.findByText("Question").click();
|
||||
@@ -188,8 +188,8 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
});
|
||||
|
||||
it("should filter entities by location", () => {
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Include items in personal collections").click();
|
||||
});
|
||||
@@ -205,7 +205,7 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
|
||||
describe("sorting", () => {
|
||||
it("should sort by name", () => {
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("sorted by name by default");
|
||||
checkListSorting({
|
||||
@@ -213,39 +213,39 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
});
|
||||
|
||||
cy.log("sorted by name ascending");
|
||||
H.DataStudio.Tasks.list().findByText("Dependency").click();
|
||||
H.DependencyDiagnostics.list().findByText("Dependency").click();
|
||||
checkListSorting({
|
||||
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_NAME,
|
||||
});
|
||||
|
||||
cy.log("sorted by name descending");
|
||||
H.DataStudio.Tasks.list().findByText("Dependency").click();
|
||||
H.DependencyDiagnostics.list().findByText("Dependency").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [...BROKEN_DEPENDENCIES_SORTED_BY_NAME].reverse(),
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by location", () => {
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("sorted by location ascending");
|
||||
H.DataStudio.Tasks.list().findByText("Location").click();
|
||||
H.DependencyDiagnostics.list().findByText("Location").click();
|
||||
checkListSorting({
|
||||
visibleEntities: BROKEN_DEPENDENCIES_SORTED_BY_LOCATION,
|
||||
});
|
||||
|
||||
cy.log("sorted by location descending");
|
||||
H.DataStudio.Tasks.list().findByText("Location").click();
|
||||
H.DependencyDiagnostics.list().findByText("Location").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [...BROKEN_DEPENDENCIES_SORTED_BY_LOCATION].reverse(),
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by dependents errors", () => {
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("sorted by dependents errors ascending");
|
||||
H.DataStudio.Tasks.list().findByText("Problems").click();
|
||||
H.DependencyDiagnostics.list().findByText("Problems").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [
|
||||
...BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS,
|
||||
@@ -253,17 +253,17 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
});
|
||||
|
||||
cy.log("sorted by dependents errors descending");
|
||||
H.DataStudio.Tasks.list().findByText("Problems").click();
|
||||
H.DependencyDiagnostics.list().findByText("Problems").click();
|
||||
checkListSorting({
|
||||
visibleEntities: BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_ERRORS,
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by dependents with errors", () => {
|
||||
H.DataStudio.Tasks.visitBrokenEntities();
|
||||
H.DependencyDiagnostics.visitBrokenDependencies();
|
||||
|
||||
cy.log("sorted by dependents with errors ascending");
|
||||
H.DataStudio.Tasks.list().findByText("Broken dependents").click();
|
||||
H.DependencyDiagnostics.list().findByText("Broken dependents").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [
|
||||
...BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS,
|
||||
@@ -271,7 +271,7 @@ describe("scenarios > dependencies > broken list", () => {
|
||||
});
|
||||
|
||||
cy.log("sorted by dependents with errors descending");
|
||||
H.DataStudio.Tasks.list().findByText("Broken dependents").click();
|
||||
H.DependencyDiagnostics.list().findByText("Broken dependents").click();
|
||||
checkListSorting({
|
||||
visibleEntities: BROKEN_DEPENDENTS_SORTED_BY_DEPENDENTS_WITH_ERRORS,
|
||||
});
|
||||
@@ -318,6 +318,7 @@ function createTransform() {
|
||||
);
|
||||
cy.get<TransformId>("@transformId").then((transformId) => {
|
||||
H.runTransform(transformId);
|
||||
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TABLE_NAME });
|
||||
H.waitForTransformRuns(
|
||||
(runs) =>
|
||||
runs.length === 1 && runs.every((run) => run.status === "succeeded"),
|
||||
@@ -483,7 +484,7 @@ function checkList({
|
||||
visibleEntities?: string[];
|
||||
hiddenEntities?: string[];
|
||||
}) {
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
visibleEntities.forEach((name) => {
|
||||
cy.findByText(name).should("exist");
|
||||
});
|
||||
@@ -504,24 +505,24 @@ function checkSidebar({
|
||||
missingColumns?: string[];
|
||||
brokenDependents?: string[];
|
||||
}) {
|
||||
H.DataStudio.Tasks.sidebar().within(() => {
|
||||
H.DataStudio.Tasks.Sidebar.header()
|
||||
H.DependencyDiagnostics.sidebar().within(() => {
|
||||
H.DependencyDiagnostics.Sidebar.header()
|
||||
.findByText(entityName)
|
||||
.should("be.visible");
|
||||
if (transformName) {
|
||||
H.DataStudio.Tasks.Sidebar.transformInfo()
|
||||
H.DependencyDiagnostics.Sidebar.transformSection()
|
||||
.findByText(transformName)
|
||||
.should("exist");
|
||||
}
|
||||
if (missingColumns) {
|
||||
H.DataStudio.Tasks.Sidebar.missingColumnsInfo().within(() => {
|
||||
H.DependencyDiagnostics.Sidebar.missingColumnsSection().within(() => {
|
||||
missingColumns.forEach((column) => {
|
||||
cy.findByText(column).should("exist");
|
||||
});
|
||||
});
|
||||
}
|
||||
if (brokenDependents) {
|
||||
H.DataStudio.Tasks.Sidebar.brokenDependentsInfo().within(() => {
|
||||
H.DependencyDiagnostics.Sidebar.brokenDependentsSection().within(() => {
|
||||
brokenDependents.forEach((dependent) => {
|
||||
cy.findByText(dependent).should("exist");
|
||||
});
|
||||
@@ -531,7 +532,7 @@ function checkSidebar({
|
||||
}
|
||||
|
||||
function checkListSorting({ visibleEntities }: { visibleEntities: string[] }) {
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
visibleEntities.forEach((name, index) => {
|
||||
cy.findByText(name)
|
||||
.parents("[data-index]")
|
||||
|
||||
@@ -104,8 +104,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("analysis", () => {
|
||||
it("should show unreferenced entities", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
ENTITY_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
@@ -114,8 +114,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should not show referenced entities", () => {
|
||||
createEntities({ withReferences: true });
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
ENTITY_NAMES.forEach((name) => {
|
||||
cy.findByText(name).should("not.exist");
|
||||
});
|
||||
@@ -126,8 +126,10 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("search", () => {
|
||||
it("should search for entities", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type(MODEL_FOR_QUESTION_DATA_SOURCE);
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.searchInput().type(
|
||||
MODEL_FOR_QUESTION_DATA_SOURCE,
|
||||
);
|
||||
checkList({
|
||||
visibleEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
|
||||
hiddenEntities: [MODEL_FOR_MODEL_DATA_SOURCE],
|
||||
@@ -136,8 +138,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should search for entities with type filters", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type("tag");
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.searchInput().type("tag");
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
MODEL_FOR_NATIVE_QUESTION_CARD_TAG,
|
||||
@@ -146,7 +148,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
hiddenEntities: [MODEL_FOR_QUESTION_DATA_SOURCE],
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().findByText("Snippet").click();
|
||||
checkList({
|
||||
visibleEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG],
|
||||
@@ -158,10 +160,10 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("filters", () => {
|
||||
it("should filter entities by type", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
checkList({ visibleEntities: ENTITY_NAMES });
|
||||
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().findByText("Model").click();
|
||||
checkList({ hiddenEntities: [MODEL_FOR_NATIVE_QUESTION_CARD_TAG] });
|
||||
|
||||
@@ -188,7 +190,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should filter by location", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
MODEL_FOR_MODEL_DATA_SOURCE,
|
||||
@@ -197,7 +199,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
],
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.filterButton().click();
|
||||
H.DependencyDiagnostics.filterButton().click();
|
||||
H.popover().findByText("Include items in personal collections").click();
|
||||
checkList({
|
||||
visibleEntities: [
|
||||
@@ -221,8 +223,8 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("sorting", () => {
|
||||
it("should sort by name", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type("Model for");
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.searchInput().type("Model for");
|
||||
|
||||
cy.log("sorted by name by default");
|
||||
checkListSorting({
|
||||
@@ -230,13 +232,13 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
});
|
||||
|
||||
cy.log("sorted by name ascending");
|
||||
H.DataStudio.Tasks.list().findByText("Name").click();
|
||||
H.DependencyDiagnostics.list().findByText("Name").click();
|
||||
checkListSorting({
|
||||
visibleEntities: MODELS_SORTED_BY_NAME,
|
||||
});
|
||||
|
||||
cy.log("sorted by name descending");
|
||||
H.DataStudio.Tasks.list().findByText("Name").click();
|
||||
H.DependencyDiagnostics.list().findByText("Name").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [...MODELS_SORTED_BY_NAME].reverse(),
|
||||
});
|
||||
@@ -244,17 +246,17 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
|
||||
it("should sort by location", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DataStudio.Tasks.searchInput().type("Model for");
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.searchInput().type("Model for");
|
||||
|
||||
cy.log("sorted by location ascending");
|
||||
H.DataStudio.Tasks.list().findByText("Location").click();
|
||||
H.DependencyDiagnostics.list().findByText("Location").click();
|
||||
checkListSorting({
|
||||
visibleEntities: MODELS_SORTED_BY_LOCATION,
|
||||
});
|
||||
|
||||
cy.log("sorted by location descending");
|
||||
H.DataStudio.Tasks.list().findByText("Location").click();
|
||||
H.DependencyDiagnostics.list().findByText("Location").click();
|
||||
checkListSorting({
|
||||
visibleEntities: [...MODELS_SORTED_BY_LOCATION].reverse(),
|
||||
});
|
||||
@@ -264,9 +266,9 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
describe("sidebar", () => {
|
||||
it("should show the sidebar for supported entities", () => {
|
||||
createEntities();
|
||||
H.DataStudio.Tasks.visitUnreferencedEntities();
|
||||
H.DependencyDiagnostics.visitUnreferencedEntities();
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(MODEL_FOR_QUESTION_DATA_SOURCE)
|
||||
.click();
|
||||
checkSidebar({
|
||||
@@ -275,21 +277,25 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list().findByText(MODEL_FOR_MODEL_DATA_SOURCE).click();
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(MODEL_FOR_MODEL_DATA_SOURCE)
|
||||
.click();
|
||||
checkSidebar({
|
||||
entityName: MODEL_FOR_MODEL_DATA_SOURCE,
|
||||
locationName: "First collection",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list().findByText(SEGMENT_FOR_QUESTION_FILTER).click();
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(SEGMENT_FOR_QUESTION_FILTER)
|
||||
.click();
|
||||
checkSidebar({
|
||||
entityName: SEGMENT_FOR_QUESTION_FILTER,
|
||||
locationName: "Orders",
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(METRIC_FOR_QUESTION_AGGREGATION)
|
||||
.click();
|
||||
checkSidebar({
|
||||
@@ -298,7 +304,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(METRIC_FOR_MODEL_AGGREGATION)
|
||||
.click();
|
||||
checkSidebar({
|
||||
@@ -307,7 +313,7 @@ describe("scenarios > dependencies > unreferenced list", () => {
|
||||
creatorName: "Bobby Tables",
|
||||
});
|
||||
|
||||
H.DataStudio.Tasks.list()
|
||||
H.DependencyDiagnostics.list()
|
||||
.findByText(SNIPPET_FOR_NATIVE_QUESTION_CARD_TAG)
|
||||
.click();
|
||||
checkSidebar({
|
||||
@@ -986,7 +992,7 @@ function checkList({
|
||||
visibleEntities?: string[];
|
||||
hiddenEntities?: string[];
|
||||
}) {
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
visibleEntities.forEach((name) => {
|
||||
cy.findByText(name).should("be.visible");
|
||||
});
|
||||
@@ -997,7 +1003,7 @@ function checkList({
|
||||
}
|
||||
|
||||
function checkListSorting({ visibleEntities }: { visibleEntities: string[] }) {
|
||||
H.DataStudio.Tasks.list().within(() => {
|
||||
H.DependencyDiagnostics.list().within(() => {
|
||||
visibleEntities.forEach((name, index) => {
|
||||
cy.findByText(name)
|
||||
.parents("[data-index]")
|
||||
@@ -1015,17 +1021,17 @@ function checkSidebar({
|
||||
locationName?: string;
|
||||
creatorName?: string;
|
||||
}) {
|
||||
H.DataStudio.Tasks.sidebar().within(() => {
|
||||
H.DataStudio.Tasks.Sidebar.header()
|
||||
H.DependencyDiagnostics.sidebar().within(() => {
|
||||
H.DependencyDiagnostics.Sidebar.header()
|
||||
.findByText(entityName)
|
||||
.should("be.visible");
|
||||
if (locationName) {
|
||||
H.DataStudio.Tasks.Sidebar.locationInfo()
|
||||
H.DependencyDiagnostics.Sidebar.locationSection()
|
||||
.findByText(locationName)
|
||||
.should("be.visible");
|
||||
}
|
||||
if (creatorName) {
|
||||
H.DataStudio.Tasks.Sidebar.creationInfo().should(
|
||||
H.DependencyDiagnostics.Sidebar.creationSection().should(
|
||||
"contain.text",
|
||||
creatorName,
|
||||
);
|
||||
|
||||
@@ -1236,6 +1236,155 @@ describe("documents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchor links", () => {
|
||||
// Helper to create filler paragraphs for scroll tests
|
||||
const createFillerParagraphs = (count: number, startIndex: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
type: "paragraph",
|
||||
attrs: { _id: `filler-paragraph-${startIndex + i}` },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `This is filler paragraph ${startIndex + i} to make the document long enough to require scrolling. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
H.createDocument({
|
||||
name: "Anchor Test Document",
|
||||
document: {
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 1, _id: "heading-block-1" },
|
||||
content: [{ type: "text", text: "First Heading" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { _id: "paragraph-block-1" },
|
||||
content: [{ type: "text", text: "Some content here" }],
|
||||
},
|
||||
// Add filler content to ensure scrolling is needed
|
||||
...createFillerParagraphs(15, 1),
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 2, _id: "heading-block-2" },
|
||||
content: [{ type: "text", text: "Second Heading" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { _id: "paragraph-block-2" },
|
||||
content: [{ type: "text", text: "More content here" }],
|
||||
},
|
||||
// More filler to push blockquote down
|
||||
...createFillerParagraphs(10, 16),
|
||||
{
|
||||
type: "blockquote",
|
||||
attrs: { _id: "blockquote-block-1" },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { _id: "quote-paragraph" },
|
||||
content: [{ type: "text", text: "A nice quote" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
},
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show anchor link icon on left side when hovering over a heading", () => {
|
||||
H.visitDocument("@documentId");
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "First Heading" })
|
||||
.realHover();
|
||||
|
||||
// Filter to visible one since all blocks have hidden buttons
|
||||
cy.get('[data-testid="anchor-link-menu"]')
|
||||
.filter(":visible")
|
||||
.first()
|
||||
.findByRole("button", { name: /copy link/i })
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should copy anchor URL to clipboard when clicking anchor link", () => {
|
||||
H.visitDocument("@documentId");
|
||||
|
||||
cy.wrap(
|
||||
Cypress.automation("remote:debugger:protocol", {
|
||||
command: "Browser.grantPermissions",
|
||||
params: {
|
||||
permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"],
|
||||
origin: window.location.origin,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "First Heading" })
|
||||
.realHover();
|
||||
|
||||
// Filter to visible one since all blocks have hidden buttons
|
||||
cy.get('[data-testid="anchor-link-menu"]')
|
||||
.filter(":visible")
|
||||
.first()
|
||||
.findByRole("button", { name: /copy link/i })
|
||||
.click();
|
||||
|
||||
cy.get("body").findByText("Copied!").should("be.visible");
|
||||
|
||||
cy.window().then((win) => {
|
||||
win.navigator.clipboard.readText().then((text) => {
|
||||
expect(text).to.include("/document/");
|
||||
expect(text).to.include("#heading-block-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should scroll to the correct block when navigating with anchor hash", () => {
|
||||
cy.get("@documentId").then((documentId) => {
|
||||
cy.visit(`/document/${documentId}#heading-block-2`);
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "Second Heading" })
|
||||
.should("be.visible");
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "First Heading" })
|
||||
.should("not.be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should still show comments menu on right side (regression check)", () => {
|
||||
H.visitDocument("@documentId");
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("heading", { name: "First Heading" })
|
||||
.realHover();
|
||||
|
||||
// Filter to visible one since all blocks have hidden menus
|
||||
cy.get('[data-testid="anchor-link-menu"]')
|
||||
.filter(":visible")
|
||||
.first()
|
||||
.findByRole("button", { name: /copy link/i })
|
||||
.should("be.visible");
|
||||
|
||||
// Comments button uses ForwardRefLink, so it's a link role not button
|
||||
cy.get('[data-testid="comments-menu"]')
|
||||
.filter(":visible")
|
||||
.first()
|
||||
.findByRole("link", { name: /comments/i })
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should display an error toast when creating a new document fails", () => {
|
||||
// setup
|
||||
|
||||
@@ -54,5 +54,15 @@ describe(
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show CORS setting", () => {
|
||||
cy.visit("/admin/embedding");
|
||||
|
||||
cy.findByTestId("admin-layout-content").within(() => {
|
||||
cy.findByTestId("embedding-app-origins-sdk-setting").should(
|
||||
"not.exist",
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -46,4 +46,12 @@ describe("scenarios > embedding > admin settings > starter", () => {
|
||||
cy.icon("gem").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show CORS setting", () => {
|
||||
cy.visit("/admin/embedding");
|
||||
|
||||
cy.findByTestId("admin-layout-content").within(() => {
|
||||
cy.findByTestId("embedding-app-origins-sdk-setting").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ const assertEditorDiffState = (opts: { exists: boolean }) => {
|
||||
.should(should);
|
||||
};
|
||||
|
||||
const sendCodgeBotMessage = (message: string) => {
|
||||
const sendCodgenBotMessage = (message: string) => {
|
||||
H.sendMetabotMessage(message);
|
||||
cy.wait("@metabotAgent").its("request.body").should("deep.include", {
|
||||
profile_id: "transforms_codegen",
|
||||
@@ -103,7 +103,7 @@ describe(
|
||||
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 1"),
|
||||
),
|
||||
});
|
||||
sendCodgeBotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Create a new native SQL transform that gives me the number 1",
|
||||
);
|
||||
assertSuggestionInSidebar({ newSourcePartial: "SELECT 1" });
|
||||
@@ -122,7 +122,7 @@ describe(
|
||||
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 2"),
|
||||
),
|
||||
});
|
||||
sendCodgeBotMessage("Make this give me the number 2 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 2 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "SELECT 1",
|
||||
newSourcePartial: "SELECT 2",
|
||||
@@ -144,7 +144,7 @@ describe(
|
||||
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 4"),
|
||||
),
|
||||
});
|
||||
sendCodgeBotMessage("Make this give me the number 4 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 4 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "SELECT 3",
|
||||
newSourcePartial: "SELECT 4",
|
||||
@@ -173,7 +173,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
sendCodgeBotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Create a new native python transform that gives me the number 1",
|
||||
);
|
||||
assertSuggestionInSidebar({
|
||||
@@ -199,7 +199,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
sendCodgeBotMessage("Make this give me the number 2 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 2 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "pd.DataFrame({'value': [1]})",
|
||||
newSourcePartial: "pd.DataFrame({'value': [2]})",
|
||||
@@ -233,7 +233,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
sendCodgeBotMessage("Make this give me the number 4 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 4 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "pd.DataFrame({'value': [3]})",
|
||||
newSourcePartial: "pd.DataFrame({'value': [4]})",
|
||||
@@ -281,7 +281,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
sendCodgeBotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Create a transform that queries the Test Model",
|
||||
);
|
||||
assertSuggestionInSidebar({ newSourcePartial: "SELECT * FROM" });
|
||||
@@ -326,7 +326,7 @@ describe(
|
||||
),
|
||||
});
|
||||
});
|
||||
sendCodgeBotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Update my SQL transform to select 2 instead of 1.",
|
||||
);
|
||||
assertSuggestionInSidebar({
|
||||
@@ -359,7 +359,7 @@ describe(
|
||||
),
|
||||
});
|
||||
});
|
||||
sendCodgeBotMessage("Make this give me the number 4 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 4 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "SELECT 3",
|
||||
newSourcePartial: "SELECT 4",
|
||||
@@ -403,7 +403,7 @@ describe(
|
||||
),
|
||||
});
|
||||
});
|
||||
sendCodgeBotMessage(
|
||||
sendCodgenBotMessage(
|
||||
"Update my SQL transform to select 2 instead of 1.",
|
||||
);
|
||||
assertSuggestionInSidebar({
|
||||
@@ -445,7 +445,7 @@ describe(
|
||||
),
|
||||
),
|
||||
});
|
||||
sendCodgeBotMessage("Make this give me the number 4 instead");
|
||||
sendCodgenBotMessage("Make this give me the number 4 instead");
|
||||
assertSuggestionInSidebar({
|
||||
oldSourcePartial: "pd.DataFrame({'value': [3]})",
|
||||
newSourcePartial: "pd.DataFrame({'value': [4]})",
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("scenarios > auth > signin > SSO", () => {
|
||||
cy.findByLabelText("Password").type("123");
|
||||
cy.button("Sign in").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Password: did not match stored password");
|
||||
cy.contains("did not match stored password");
|
||||
});
|
||||
|
||||
it("should pass `redirect` search params from Google button screen to email/password screen (metabase#16216)", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { H } = cy;
|
||||
|
||||
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
import type {
|
||||
@@ -1421,17 +1421,36 @@ describe("issue #47005", () => {
|
||||
H.restore("postgres-12");
|
||||
cy.signInAsNormalUser();
|
||||
|
||||
H.createQuestion({
|
||||
H.createCardWithQuery({
|
||||
name: "Question A",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
stages: [
|
||||
{
|
||||
source: {
|
||||
type: "table",
|
||||
id: ORDERS_ID,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}).then(({ body: question }) => {
|
||||
H.createQuestion(
|
||||
H.createCardWithQuery(
|
||||
{
|
||||
name: "Question B",
|
||||
metadata: {
|
||||
cardIds: [question.id],
|
||||
},
|
||||
query: {
|
||||
"source-table": "card__" + question.id,
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
stages: [
|
||||
{
|
||||
source: {
|
||||
type: "card",
|
||||
id: question.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
@@ -1454,13 +1473,30 @@ describe("issue 66210", () => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
H.createQuestion({
|
||||
H.createCardWithQuery({
|
||||
name: METRIC_NAME,
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
type: "metric",
|
||||
query: {
|
||||
databaseId: SAMPLE_DB_ID,
|
||||
stages: [
|
||||
{
|
||||
source: {
|
||||
type: "table",
|
||||
id: ORDERS_ID,
|
||||
},
|
||||
aggregations: [
|
||||
{
|
||||
name: "Count",
|
||||
value: {
|
||||
type: "operator",
|
||||
operator: "count",
|
||||
args: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
cy.visit("/");
|
||||
@@ -1494,3 +1530,85 @@ describe("issue #67903", () => {
|
||||
cy.findByTestId("table-header").should("not.be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 68574", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
|
||||
const questionDetails: NativeQuestionDetails = {
|
||||
name: "Question 1",
|
||||
native: {
|
||||
query: "SELECT * FROM ORDERS WHERE CREATED_AT > {{ start }}",
|
||||
"template-tags": {
|
||||
start: {
|
||||
type: "date",
|
||||
name: "start",
|
||||
"display-name": "Start",
|
||||
id: "1",
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: [
|
||||
createMockParameter({
|
||||
id: "1",
|
||||
slug: "start",
|
||||
required: true,
|
||||
name: "Start",
|
||||
type: "date/single",
|
||||
target: ["variable", ["template-tag", "start"]],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
H.createNativeQuestion(questionDetails, { wrapId: true });
|
||||
});
|
||||
|
||||
it("should be possible to run a query for a empty required parameter without a default value (metabase#68574)", () => {
|
||||
updateFormattingSettings({
|
||||
date_style: "D MMMM, YYYY",
|
||||
date_abbreviate: false,
|
||||
});
|
||||
visitQuestion("2024-01-01");
|
||||
assertParameterFormat("1 January, 2024");
|
||||
|
||||
cy.log("change the date format");
|
||||
updateFormattingSettings({
|
||||
date_style: "dddd, MMMM D, YYYY",
|
||||
date_abbreviate: false,
|
||||
});
|
||||
visitQuestion("2024-01-01");
|
||||
assertParameterFormat("Monday, January 1, 2024");
|
||||
|
||||
cy.log("enable date abbreviation");
|
||||
updateFormattingSettings({
|
||||
date_style: "dddd, MMMM D, YYYY",
|
||||
date_abbreviate: true,
|
||||
});
|
||||
visitQuestion("2024-01-01");
|
||||
assertParameterFormat("Mon, Jan 1, 2024");
|
||||
|
||||
cy.log("even when the setting is unset, it should render a valid format");
|
||||
updateFormattingSettings(undefined);
|
||||
visitQuestion("2024-01-01");
|
||||
assertParameterFormat("January 1, 2024");
|
||||
});
|
||||
|
||||
function updateFormattingSettings(settings: any) {
|
||||
H.updateSetting("custom-formatting", {
|
||||
"type/Temporal": settings,
|
||||
});
|
||||
}
|
||||
|
||||
function visitQuestion(value: string) {
|
||||
cy.get("@questionId").then((id) => {
|
||||
cy.visit(`/question/${id}?start=${value}`);
|
||||
});
|
||||
}
|
||||
|
||||
function assertParameterFormat(value: string) {
|
||||
cy.findByTestId("parameter-value-widget-target")
|
||||
.should("be.visible")
|
||||
.should("contain.text", value);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -696,6 +696,99 @@ describe("scenarios > visualizations > table > dashboards context", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should update row heights correctly when sorting with text wrapping enabled (metabase#61164)", () => {
|
||||
// This test verifies that when sorting changes, row heights are recalculated
|
||||
// based on the new row content at each position (not the old cached heights)
|
||||
H.createQuestionAndDashboard({
|
||||
questionDetails: {
|
||||
name: "reviews for sorting test",
|
||||
type: "model",
|
||||
query: {
|
||||
"source-table": SAMPLE_DATABASE.REVIEWS_ID,
|
||||
limit: 10,
|
||||
},
|
||||
visualization_settings: {
|
||||
"table.column_widths": [200, 100, 100, 100, 100],
|
||||
column_settings: {
|
||||
'["name","BODY"]': {
|
||||
text_wrapping: true,
|
||||
},
|
||||
},
|
||||
"table.columns": [
|
||||
{
|
||||
name: "BODY",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "RATING",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "ID",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "PRODUCT_ID",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "REVIEWER",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
dashboardDetails: {
|
||||
name: "Dashboard",
|
||||
},
|
||||
cardDetails: {
|
||||
size_x: 24,
|
||||
size_y: 12,
|
||||
},
|
||||
}).then(({ body: { dashboard_id } }) => {
|
||||
H.visitDashboard(dashboard_id);
|
||||
|
||||
// Wait for table to render, then sort and verify rows don't overlap
|
||||
H.tableInteractive()
|
||||
.find("[data-index=0]")
|
||||
.should("exist")
|
||||
.then(() => {
|
||||
H.tableHeaderClick("Rating");
|
||||
|
||||
// Verify rows don't overlap by checking their bounding rects
|
||||
H.tableInteractive()
|
||||
.find("[role=row]")
|
||||
.then(($rows) => {
|
||||
const rects = $rows
|
||||
.toArray()
|
||||
.map((row) => row.getBoundingClientRect())
|
||||
.sort((a, b) => a.top - b.top);
|
||||
|
||||
// Each row's top should equal the previous row's bottom (no overlap)
|
||||
for (let i = 1; i < rects.length; i++) {
|
||||
expect(rects[i].top).to.equal(rects[i - 1].bottom);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort again (descending) to verify heights update on subsequent sorts
|
||||
H.tableHeaderClick("Rating");
|
||||
|
||||
H.tableInteractive()
|
||||
.find("[role=row]")
|
||||
.then(($rows) => {
|
||||
const rects = $rows
|
||||
.toArray()
|
||||
.map((row) => row.getBoundingClientRect())
|
||||
.sort((a, b) => a.top - b.top);
|
||||
|
||||
for (let i = 1; i < rects.length; i++) {
|
||||
expect(rects[i].top).to.equal(rects[i - 1].bottom);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should support the row index setting", () => {
|
||||
H.visitDashboard(ORDERS_DASHBOARD_ID);
|
||||
H.editDashboard();
|
||||
|
||||
@@ -65,14 +65,16 @@
|
||||
"Adds to `user` a set of boolean flags indicating whether or not current user has access to advanced permissions.
|
||||
This function is meant to be used for GET /api/user/current."
|
||||
[user]
|
||||
(let [permissions-set @api/*current-user-permissions-set*
|
||||
user-id api/*current-user-id*]
|
||||
(let [permissions-set @api/*current-user-permissions-set*
|
||||
user-id api/*current-user-id*
|
||||
can-access-data-model (perms/user-has-any-perms-of-type? user-id :perms/manage-table-metadata)]
|
||||
(update user :permissions assoc
|
||||
:can_access_setting (perms/set-has-application-permission-of-type? permissions-set :setting)
|
||||
:can_access_subscription (perms/set-has-application-permission-of-type? permissions-set :subscription)
|
||||
:can_access_monitoring (perms/set-has-application-permission-of-type? permissions-set :monitoring)
|
||||
:can_access_data_model (perms/user-has-any-perms-of-type? user-id :perms/manage-table-metadata)
|
||||
:can_access_data_model can-access-data-model
|
||||
:can_access_db_details (perms/user-has-any-perms-of-type? user-id :perms/manage-database)
|
||||
:is_data_analyst api/*is-data-analyst?*
|
||||
:is_group_manager api/*is-group-manager?*)))
|
||||
|
||||
(defenterprise current-user-has-application-permissions?
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{:name :sso-saml
|
||||
:available (premium-features/enable-sso-saml?)
|
||||
:enabled (sso-settings/saml-enabled)}
|
||||
{:name :sso-slack
|
||||
:available (premium-features/enable-sso-slack?)
|
||||
:enabled (sso-settings/slack-connect-enabled)}
|
||||
{:name :scim
|
||||
:available (premium-features/enable-scim?)
|
||||
:enabled (boolean (scim/scim-enabled))}
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
[:entity_type {:optional true} [:maybe :string]]
|
||||
[:owner_email {:optional true} [:maybe :string]]
|
||||
[:owner_user_id {:optional true} [:maybe :int]]]]]
|
||||
(api/check-superuser)
|
||||
(api/check-data-analyst)
|
||||
(let [where (table-selectors->filter (select-keys body [:database_ids :schema_ids :table_ids]))
|
||||
set-ks [:data_authority
|
||||
:data_source
|
||||
@@ -214,7 +214,7 @@
|
||||
[_route-params
|
||||
_query-params
|
||||
body :- ::table-selectors]
|
||||
(api/check-superuser)
|
||||
(api/check-data-analyst)
|
||||
(let [fields [:model/Table :id :db_id :name :display_name :schema :is_published]
|
||||
where (table-selectors->filter (select-keys body [:database_ids :schema_ids :table_ids]))
|
||||
selected-tables (t2/select fields {:where where :limit 2})
|
||||
@@ -235,7 +235,7 @@
|
||||
[_route-params
|
||||
_query-params
|
||||
body :- ::table-selectors]
|
||||
(api/check-superuser)
|
||||
(api/check-data-analyst)
|
||||
(let [target-collection (api/let-404 [colls (seq (t2/select :model/Collection
|
||||
:type collection/library-data-collection-type
|
||||
{:limit 2}))]
|
||||
@@ -267,7 +267,7 @@
|
||||
[_route-params
|
||||
_query-params
|
||||
body :- ::table-selectors]
|
||||
(api/check-superuser)
|
||||
(api/check-data-analyst)
|
||||
(let [where (table-selectors->filter (select-keys body [:database_ids :schema_ids :table_ids]))
|
||||
downstream-ids (all-downstream-table-ids where)
|
||||
update-where (if (seq downstream-ids)
|
||||
@@ -299,7 +299,7 @@
|
||||
[_
|
||||
_
|
||||
body :- ::table-selectors]
|
||||
(api/check-superuser)
|
||||
(api/check-data-analyst)
|
||||
(let [tables (t2/select :model/Table {:where (table-selectors->filter body), :order-by [[:id]]})
|
||||
db-ids (sort (set (map :db_id tables)))]
|
||||
(doseq [database (t2/select :model/Database :id [:in db-ids])]
|
||||
@@ -318,7 +318,7 @@
|
||||
[_
|
||||
_
|
||||
body :- ::table-selectors]
|
||||
(api/check-superuser)
|
||||
(api/check-data-analyst)
|
||||
(let [tables (t2/select :model/Table {:where (table-selectors->filter body), :order-by [[:id]]})]
|
||||
;; same permission skip as the single-table api, see comment in /:id/rescan_values
|
||||
(doseq [table tables]
|
||||
@@ -331,7 +331,7 @@
|
||||
[_
|
||||
_
|
||||
body :- ::table-selectors]
|
||||
(api/check-superuser)
|
||||
(api/check-data-analyst)
|
||||
(let [tables (t2/select :model/Table {:where (table-selectors->filter body), :order-by [[:id]]})]
|
||||
(let [field-ids-to-delete-q {:select [:id]
|
||||
:from [(t2/table-name :model/Field)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns metabase-enterprise.dependencies.api
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.dependencies.core :as dependencies]
|
||||
[metabase-enterprise.dependencies.dependency-types :as deps.dependency-types]
|
||||
@@ -476,14 +477,18 @@
|
||||
|
||||
(defn- node-downstream-errors
|
||||
"Fetches errors caused by the given source entities (what downstream entities they're breaking).
|
||||
Filters out errors where the analyzed entity is not visible to the current user.
|
||||
Unlike `node-errors` which fetches errors on an entity, this fetches errors that
|
||||
the entity is causing in other entities that depend on it."
|
||||
[nodes-by-type]
|
||||
(letfn [(errors-by-source-type-and-id [[source-type ids]]
|
||||
(when (seq ids)
|
||||
(let [finding-errors (t2/select :model/AnalysisFindingError
|
||||
:source_entity_type source-type
|
||||
:source_entity_id [:in ids])]
|
||||
{:where [:and
|
||||
[:= :source_entity_type (name source-type)]
|
||||
[:in :source_entity_id ids]
|
||||
(visible-entities-filter-clause
|
||||
:analyzed_entity_type :analyzed_entity_id)]})]
|
||||
(u/group-by (juxt :source_entity_type :source_entity_id)
|
||||
identity conj #{} finding-errors))))]
|
||||
(->> nodes-by-type
|
||||
@@ -492,6 +497,7 @@
|
||||
|
||||
(defn- node-errors
|
||||
"Fetches and normalizes AnalysisFindingErrors for the given entities.
|
||||
Filters out errors where the source entity is not visible to the current user.
|
||||
Returns {[entity-type entity-id] #{error-maps...}}, or nil if none."
|
||||
[nodes-by-type]
|
||||
(letfn [(normalize-finding-error
|
||||
@@ -501,8 +507,13 @@
|
||||
(errors-by-entity-type-and-id [[type ids]]
|
||||
(when (seq ids)
|
||||
(let [finding-errors (t2/select :model/AnalysisFindingError
|
||||
:analyzed_entity_type type
|
||||
:analyzed_entity_id [:in ids])]
|
||||
{:where [:and
|
||||
[:= :analyzed_entity_type (name type)]
|
||||
[:in :analyzed_entity_id ids]
|
||||
[:or
|
||||
[:= :source_entity_type nil]
|
||||
(visible-entities-filter-clause
|
||||
:source_entity_type :source_entity_id)]]})]
|
||||
(u/group-by (juxt :analyzed_entity_type :analyzed_entity_id)
|
||||
normalize-finding-error conj #{} finding-errors))))]
|
||||
(->> nodes-by-type
|
||||
@@ -587,20 +598,84 @@
|
||||
{:nodes (expanded-nodes downstream-graph nodes {:include-errors? false})
|
||||
:edges edges})))
|
||||
|
||||
(def ^:private sort-directions
|
||||
"Valid sort directions for dependency item endpoints."
|
||||
#{:asc :desc})
|
||||
|
||||
(def ^:private dependents-sort-columns
|
||||
"Valid sort columns for the /graph/dependents endpoint."
|
||||
#{:name :location :view-count})
|
||||
|
||||
(defn- entity-name
|
||||
"Returns the name string for an entity based on its type. Might return nil."
|
||||
[entity]
|
||||
(let [data (:data entity)]
|
||||
(case (:type entity)
|
||||
:table (:display_name data)
|
||||
:sandbox nil
|
||||
(:name data))))
|
||||
|
||||
(defn- entity-location
|
||||
"Returns the location string for an entity based on its type. Might return nil."
|
||||
[entity]
|
||||
(let [data (:data entity)]
|
||||
(case (:type entity)
|
||||
:card (or (-> data :dashboard :name)
|
||||
(-> data :document :name)
|
||||
(-> data :collection :name))
|
||||
:table (-> data :db :name)
|
||||
(:transform :snippet :dashboard :document) (-> data :collection :name)
|
||||
:sandbox nil
|
||||
(:segment :measure) (-> data :table :display_name)
|
||||
nil)))
|
||||
|
||||
(defn- string-matches-query?
|
||||
"Returns true if `s` is a string and its lower-case version contains the string `query`.
|
||||
`query` is expected to be lower-case."
|
||||
[s query]
|
||||
(some-> s u/lower-case-en (str/includes? query)))
|
||||
|
||||
(defn- entity-matches-query?
|
||||
"Returns true if `entity`'s name or location contains the string `query` (case-insensitive)."
|
||||
[entity query]
|
||||
(let [q (u/lower-case-en query)]
|
||||
(or (string-matches-query? (entity-name entity) q)
|
||||
(string-matches-query? (entity-location entity) q))))
|
||||
|
||||
(defn- in-personal-collection?
|
||||
"Returns true if `entity` is in a personal collection."
|
||||
[entity]
|
||||
(get-in entity [:data :collection :is_personal]))
|
||||
|
||||
(defn- sort-dependents
|
||||
"Sort `entities` by `sort-column` in `sort-direction`."
|
||||
[entities sort-column sort-direction]
|
||||
(let [key-fn (case sort-column
|
||||
:name #(some-> (entity-name %) u/lower-case-en)
|
||||
:location #(some-> (entity-location %) u/lower-case-en)
|
||||
:view-count #(or (-> % :data :view_count) 0))
|
||||
comp-fn (cond->> compare
|
||||
(= sort-direction :desc) (comp -))]
|
||||
(sort-by key-fn comp-fn entities)))
|
||||
|
||||
(def ^:private dependents-args
|
||||
[:map
|
||||
[:id ms/PositiveInt]
|
||||
[:type ::deps.dependency-types/dependency-types]
|
||||
[:dependent_types {:optional true}
|
||||
[:id ms/PositiveInt]
|
||||
[:type ::deps.dependency-types/dependency-types]
|
||||
[:dependent_types {:optional true}
|
||||
[:or
|
||||
::deps.dependency-types/dependency-types
|
||||
[:sequential ::deps.dependency-types/dependency-types]]]
|
||||
[:dependent_card_types {:optional true}
|
||||
[:dependent_card_types {:optional true}
|
||||
[:or
|
||||
(ms/enum-decode-keyword lib.schema.metadata/card-types)
|
||||
[:sequential (ms/enum-decode-keyword lib.schema.metadata/card-types)]]]
|
||||
[:archived {:optional true} :boolean]
|
||||
[:broken {:optional true} :boolean]])
|
||||
[:archived {:optional true} :boolean]
|
||||
[:broken {:optional true} :boolean]
|
||||
[:query {:optional true} :string]
|
||||
[:include_personal_collections {:optional true} :boolean]
|
||||
[:sort_column {:optional true} (ms/enum-decode-keyword dependents-sort-columns)]
|
||||
[:sort_direction {:optional true} (ms/enum-decode-keyword sort-directions)]])
|
||||
|
||||
(api.macros/defendpoint :get "/graph/dependents" :- [:sequential ::entity]
|
||||
"Returns a list of dependents for the specified entity.
|
||||
@@ -615,9 +690,17 @@
|
||||
- `dependent_card_types`: Card types to filter by when dependent_types includes :card.
|
||||
Ignored if dependent_types doesn't include :card. Example: ?dependent_card_types=question&dependent_card_types=model
|
||||
- `archived`: Include entities in archived collections (default: false)
|
||||
- `broken`: Return only broken entities (default: false)"
|
||||
- `broken`: Return only broken entities (default: false)
|
||||
- `query`: Search string to filter results by name or location (case-insensitive)
|
||||
- `include_personal_collections`: Include items in personal collections (default: false)
|
||||
- `sort_column`: Column to sort by - name, location, or view-count (default: name)
|
||||
- `sort_direction`: Sort direction - asc or desc (default: asc)"
|
||||
[_route-params
|
||||
{:keys [id type dependent_types dependent_card_types archived broken]} :- dependents-args]
|
||||
{:keys [id type dependent_types dependent_card_types archived broken
|
||||
query include_personal_collections sort_column sort_direction]
|
||||
:or {include_personal_collections false
|
||||
sort_column :name
|
||||
sort_direction :asc}} :- dependents-args]
|
||||
(api/read-check (deps.dependency-types/dependency-type->model type) id)
|
||||
(lib-be/with-metadata-provider-cache
|
||||
(let [graph-opts {:include-archived-items (if archived :all :exclude)
|
||||
@@ -632,14 +715,26 @@
|
||||
card-types-set (cond
|
||||
(nil? dependent_card_types) lib.schema.metadata/card-types
|
||||
(sequential? dependent_card_types) (set dependent_card_types)
|
||||
:else #{dependent_card_types})]
|
||||
(->> (expanded-nodes downstream-graph nodes {:include-errors? false})
|
||||
:else #{dependent_card_types})
|
||||
dependents-filter
|
||||
(comp
|
||||
;; Filter by dependent types and card types
|
||||
(filter (fn [node]
|
||||
(and (or (nil? dep-types-set)
|
||||
(contains? dep-types-set (:type node)))
|
||||
(or (not= (:type node) :card)
|
||||
(nil? card-types-set)
|
||||
(contains? card-types-set (-> node :data :type))))))))))
|
||||
(contains? card-types-set (-> node :data :type))))))
|
||||
;; Filter out personal collections unless explicitly included
|
||||
(if include_personal_collections
|
||||
identity
|
||||
(remove in-personal-collection?))
|
||||
;; Filter by query (sandboxes are excluded since they have no name or location)
|
||||
(if query
|
||||
(filter #(entity-matches-query? % query))
|
||||
identity))]
|
||||
(-> (into [] dependents-filter (expanded-nodes downstream-graph nodes {:include-errors? false}))
|
||||
(sort-dependents sort_column sort_direction)))))
|
||||
|
||||
(defn- entity-type-config
|
||||
[entity-type]
|
||||
@@ -673,20 +768,28 @@
|
||||
(:segment :measure) :table.display_name)}))
|
||||
|
||||
(defn- query-type-join-and-filter
|
||||
[query-type entity-type]
|
||||
[query-type entity-type {:keys [include-archived-items]}]
|
||||
(case query-type
|
||||
:unreferenced {:join [:dependency [:and
|
||||
[:= :dependency.to_entity_id :entity.id]
|
||||
[:= :dependency.to_entity_type (name entity-type)]]]
|
||||
:filter [:= :dependency.id nil]}
|
||||
[:= :dependency.to_entity_type (name entity-type)]
|
||||
(visible-entities-filter-clause
|
||||
:dependency.from_entity_type
|
||||
:dependency.from_entity_id
|
||||
{:include-archived-items include-archived-items})]]
|
||||
:join-filter [:= :dependency.id nil]}
|
||||
:broken {:join [:analysis_finding [:and
|
||||
[:= :analysis_finding.analyzed_entity_id :entity.id]
|
||||
[:= :analysis_finding.analyzed_entity_type (name entity-type)]]]
|
||||
:filter [:= :analysis_finding.result false]}
|
||||
:join-filter [:= :analysis_finding.result false]}
|
||||
:breaking {:join [:analysis_finding_error [:and
|
||||
[:= :analysis_finding_error.source_entity_id :entity.id]
|
||||
[:= :analysis_finding_error.source_entity_type (name entity-type)]]]
|
||||
:filter [:!= :analysis_finding_error.id nil]}))
|
||||
[:= :analysis_finding_error.source_entity_type (name entity-type)]
|
||||
(visible-entities-filter-clause
|
||||
:analysis_finding_error.analyzed_entity_type
|
||||
:analysis_finding_error.analyzed_entity_id
|
||||
{:include-archived-items include-archived-items})]]
|
||||
:join-filter [:!= :analysis_finding_error.id nil]}))
|
||||
|
||||
(defn- location-joins-for-entity
|
||||
"Returns the set of join keywords needed for location-based operations."
|
||||
@@ -755,7 +858,9 @@
|
||||
:from [:analysis_finding_error]
|
||||
:where [:and
|
||||
[:= :source_entity_id :entity.id]
|
||||
[:= :source_entity_type (name entity-type)]]}
|
||||
[:= :source_entity_type (name entity-type)]
|
||||
(visible-entities-filter-clause
|
||||
:analyzed_entity_type :analyzed_entity_id)]}
|
||||
:sort-joins #{}}
|
||||
:dependents-with-errors {:sort-column {:select [[[:count [:distinct (if (= :mysql (mdb/db-type))
|
||||
[:concat :analyzed_entity_id [:inline "-"] :analyzed_entity_type]
|
||||
@@ -763,7 +868,9 @@
|
||||
:from [:analysis_finding_error]
|
||||
:where [:and
|
||||
[:= :source_entity_id :entity.id]
|
||||
[:= :source_entity_type (name entity-type)]]}
|
||||
[:= :source_entity_type (name entity-type)]
|
||||
(visible-entities-filter-clause
|
||||
:analyzed_entity_type :analyzed_entity_id)]}
|
||||
:sort-joins #{}}
|
||||
{:sort-column name-column
|
||||
:sort-joins #{}}))
|
||||
@@ -778,11 +885,14 @@
|
||||
(:table joins) (conj [:metabase_table :table] [:= :entity.table_id :table.id])))
|
||||
|
||||
(defn- dependency-items-query
|
||||
[{:keys [query-type entity-type sort-column] :as params}]
|
||||
[{:keys [query-type entity-type sort-column include-archived-items] :as params}]
|
||||
(let [{:keys [table-name name-column location-column] :as config} (entity-type-config entity-type)
|
||||
{:keys [join filter]} (query-type-join-and-filter query-type entity-type)
|
||||
{:keys [join join-filter]} (query-type-join-and-filter query-type entity-type
|
||||
{:include-archived-items include-archived-items})
|
||||
{:keys [filters filter-joins]} (build-optional-filters params config)
|
||||
{:keys [sort-column sort-joins]} (sort-key-cols-and-joins sort-column entity-type name-column location-column)
|
||||
visible-filter (visible-entities-filter-clause (name entity-type) :entity.id
|
||||
{:include-archived-items include-archived-items})
|
||||
all-required-joins (set/union filter-joins sort-joins)
|
||||
select-clause [[[:inline (name entity-type)] :entity_type]
|
||||
[:entity.id :entity_id]
|
||||
@@ -790,16 +900,12 @@
|
||||
{(if (= query-type :breaking) :select-distinct :select) select-clause
|
||||
:from [[table-name :entity]]
|
||||
:left-join (build-left-joins join all-required-joins)
|
||||
:where (into [:and filter] (keep identity) filters)}))
|
||||
:where (into [:and join-filter visible-filter] (keep identity) filters)}))
|
||||
|
||||
(def ^:private sort-columns
|
||||
"Valid sort columns for dependency item endpoints."
|
||||
(def ^:private breaking-items-sort-columns
|
||||
"Valid sort columns for /graph/broken and /graph/unreferenced endpoints."
|
||||
#{:name :location :dependents-with-errors :dependents-errors})
|
||||
|
||||
(def ^:private sort-directions
|
||||
"Valid sort directions for dependency item endpoints."
|
||||
#{:asc :desc})
|
||||
|
||||
(def ^:private dependency-items-args
|
||||
[:map
|
||||
[:types {:optional true} [:or
|
||||
@@ -811,7 +917,7 @@
|
||||
[:query {:optional true} :string]
|
||||
[:archived {:optional true} :boolean]
|
||||
[:include_personal_collections {:optional true} :boolean]
|
||||
[:sort_column {:optional true} (ms/enum-decode-keyword sort-columns)]
|
||||
[:sort_column {:optional true} (ms/enum-decode-keyword breaking-items-sort-columns)]
|
||||
[:sort_direction {:optional true} (ms/enum-decode-keyword sort-directions)]])
|
||||
|
||||
(def ^:private dependency-items-response
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
(:require
|
||||
[metabase-enterprise.dependencies.native-validation :as deps.native]
|
||||
[metabase-enterprise.dependencies.schema :as deps.schema]
|
||||
[metabase-enterprise.transforms.core :as transforms]
|
||||
[metabase.documents.prose-mirror :as prose-mirror]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
@@ -43,19 +44,31 @@
|
||||
query-deps
|
||||
param-card-ids)))
|
||||
|
||||
(mu/defn upstream-deps:python-transform :- ::deps.schema/upstream-deps
|
||||
"Given a Python Transform (in Toucan form), return its upstream dependencies."
|
||||
[transform]
|
||||
{:table (->> (get-in transform [:source :source-tables])
|
||||
vals
|
||||
(into #{}
|
||||
(keep #(if (map? %)
|
||||
(:table_id %)
|
||||
%))))})
|
||||
|
||||
(mu/defn upstream-deps:transform :- ::deps.schema/upstream-deps
|
||||
"Given a Transform (in Toucan form), return its upstream dependencies."
|
||||
[{{:keys [query], source-type :type} :source :as transform} :-
|
||||
[{{:keys [query]} :source :as transform} :-
|
||||
[:map
|
||||
[:source [:multi {:dispatch (comp keyword :type)}
|
||||
[:query
|
||||
[:map [:query ::lib.schema/query]]]
|
||||
[:python
|
||||
[:map]]]]]]
|
||||
(if (= (keyword source-type) :query)
|
||||
(upstream-deps:query query)
|
||||
(do (log/warnf "Don't know how to analyze the deps of Transform %d with source type '%s'" (:id transform) source-type)
|
||||
{})))
|
||||
(let [source-type (transforms/transform-type transform)]
|
||||
(case source-type
|
||||
:query (upstream-deps:query query)
|
||||
:python (upstream-deps:python-transform transform)
|
||||
(do (log/warnf "Don't know how to analyze the deps of Transform %d with source type '%s'" (:id transform) source-type)
|
||||
{}))))
|
||||
|
||||
(mu/defn upstream-deps:snippet :- ::deps.schema/upstream-deps
|
||||
"Given a native query snippet, return its upstream dependencies in the usual `{entity-type #{1 2 3}}` format."
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[metabase-enterprise.dependencies.dependency-types :as deps.dependency-types]
|
||||
[metabase-enterprise.dependencies.findings :as deps.findings]
|
||||
[metabase-enterprise.dependencies.models.dependency :as models.dependency]
|
||||
[metabase-enterprise.transforms.core :as transforms]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
@@ -89,8 +90,8 @@
|
||||
(derive :event/update-transform ::transform-deps)
|
||||
|
||||
;; On *saving* a transform, the upstream deps of its query are computed and saved.
|
||||
(defn- drop-outdated-target-dep! [{:keys [id source target] :as _transform}]
|
||||
(let [db-id (some-> source :query :database)
|
||||
(defn- drop-outdated-target-dep! [{:keys [id target] :as transform}]
|
||||
(let [db-id (transforms/transform-source-database transform)
|
||||
downstream-table-ids (t2/select-fn-set :from_entity_id :model/Dependency
|
||||
:from_entity_type :table
|
||||
:to_entity_type :transform
|
||||
@@ -276,20 +277,23 @@
|
||||
(when (premium-features/has-feature? :dependencies)
|
||||
(t2/delete! :model/Dependency :from_entity_type :measure :from_entity_id (:id object))))
|
||||
|
||||
(defn- check-dependents! [type object recur-through-transforms?]
|
||||
(defn- check-all-dependents! [type->objects recur-through-transforms?]
|
||||
(let [graph (if recur-through-transforms?
|
||||
(models.dependency/graph-dependents)
|
||||
(models.dependency/filtered-graph-dependents
|
||||
nil
|
||||
(fn [type-field _id-field]
|
||||
[:not= type-field "transform"])))
|
||||
children-map (models.dependency/transitive-mbql-dependents graph {type [object]})]
|
||||
children-map (models.dependency/transitive-mbql-dependents graph type->objects)]
|
||||
(doseq [[type children] children-map
|
||||
:when (deps.findings/supported-entities type)
|
||||
instances (partition 50 50 nil children)]
|
||||
(-> (t2/select (deps.dependency-types/dependency-type->model type) :id [:in instances])
|
||||
deps.findings/analyze-instances!))))
|
||||
|
||||
(defn- check-dependents! [type object recur-through-transforms?]
|
||||
(check-all-dependents! {type [object]} recur-through-transforms?))
|
||||
|
||||
(derive ::check-card-dependents :metabase/event)
|
||||
(derive :event/card-create ::check-card-dependents)
|
||||
(derive :event/card-update ::check-card-dependents)
|
||||
@@ -335,3 +339,49 @@
|
||||
(when (premium-features/has-feature? :dependencies)
|
||||
(lib-be/with-metadata-provider-cache
|
||||
(check-dependents! :transform {:id (:transform-id object)} true))))
|
||||
|
||||
(defn- synced-db->direct-dependents-of-changed-tables
|
||||
"Given the `:db_id` of a freshly synced database, this examines all tables in the DB which were updated, or have
|
||||
fields which were updated, since the last time any cards depending on them were analyzed.
|
||||
|
||||
It is important that this doesn't re-run the analysis for all dependents of every table whose DB got synced -
|
||||
most of the tables have no change every time.
|
||||
|
||||
Returns the set of table IDs which have dependents that need re-analysis, possibly empty."
|
||||
[db-id]
|
||||
(t2/select-fn-set :table_id :model/AnalysisFinding
|
||||
{:select [:field_updates/table_id]
|
||||
:from [[{:select [[:table/id :table_id]
|
||||
[:table/updated_at :last_table_update]
|
||||
[[:max :field/updated_at] :last_field_update]]
|
||||
:from [[(t2/table-name :model/Table) :table]]
|
||||
:left-join [[(t2/table-name :model/Field) :field]
|
||||
[:= :field/table_id :table/id]]
|
||||
:where [:= :table/db_id db-id]
|
||||
:group-by [:table/id
|
||||
:table/updated_at]}
|
||||
:field_updates]]
|
||||
:inner-join [[(t2/table-name :model/Dependency) :dep]
|
||||
[:and
|
||||
[:= :dep/to_entity_type [:inline "table"]]
|
||||
[:= :field_updates/table_id :dep/to_entity_id]]
|
||||
[(t2/table-name :model/AnalysisFinding) :finding]
|
||||
[:and
|
||||
[:= :finding/analyzed_entity_type :dep/from_entity_type]
|
||||
[:= :finding/analyzed_entity_id :dep/from_entity_id]]]
|
||||
:where [:and
|
||||
[:!= :finding/analyzed_entity_id nil]
|
||||
[:or
|
||||
[:< :finding/analyzed_at :field_updates/last_table_update]
|
||||
[:< :finding/analyzed_at :field_updates/last_field_update]]]}))
|
||||
|
||||
(derive ::sync-completed-on-database :metabase/event)
|
||||
(derive :event/sync-end ::sync-completed-on-database)
|
||||
|
||||
(methodical/defmethod events/publish-event! ::sync-completed-on-database
|
||||
[_ {db-id :database_id}]
|
||||
(when (premium-features/has-feature? :dependencies)
|
||||
(lib-be/with-metadata-provider-cache
|
||||
(let [changes (synced-db->direct-dependents-of-changed-tables db-id)]
|
||||
(when (seq changes)
|
||||
(check-all-dependents! {:table changes} true))))))
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
Version history:
|
||||
- 3: Initial version
|
||||
- 4: Disable naive sql validation
|
||||
- 5: Added source entity tracking in analysis_finding_error table"
|
||||
5)
|
||||
- 5: Added source entity tracking in analysis_finding_error table
|
||||
- 6: Removed validate prefix from error_type in analysis_finding_error"
|
||||
6)
|
||||
|
||||
(defn- error->finding-error-row
|
||||
"Convert an error from lib/find-bad-refs-with-source to a row for analysis_finding_error table.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
(def current-dependency-analysis-version
|
||||
"Current version of the dependency analysis logic.
|
||||
This should be incremented when the dependency analysis logic changes."
|
||||
4)
|
||||
5)
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Dependency [_model] :dependency)
|
||||
|
||||
@@ -157,12 +157,16 @@
|
||||
(filtered-graph key-dependents destination-filter-fn source-filter-fn)))
|
||||
|
||||
(defn entities->nodes
|
||||
"Converts a map of entities `{entity-type [{:id 1, ...} ...]}` into a list of nodes `[[entity-type entity-id]]`."
|
||||
"Converts a map of entities `{entity-type [{:id 1, ...} ...]}` or entity IDs `{entity-type [1]}` into a list of nodes
|
||||
`[[entity-type entity-id]]`."
|
||||
[entities-map]
|
||||
(for [[entity-type entities] entities-map
|
||||
entity entities
|
||||
:when (:id entity)]
|
||||
[entity-type (:id entity)]))
|
||||
:let [id (if (number? entity)
|
||||
entity
|
||||
(:id entity))]
|
||||
:when id]
|
||||
[entity-type id]))
|
||||
|
||||
(defn group-nodes
|
||||
"Groups a list of nodes `[[entity-type entity-id]]` by their type."
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
(api.macros/defendpoint :post "/"
|
||||
"Creates the Library if it doesn't exist. Returns the created collection.
|
||||
|
||||
Requires superuser permissions."
|
||||
Requires data analyst or superuser permissions."
|
||||
[_route
|
||||
_query
|
||||
_body]
|
||||
(api/check-superuser)
|
||||
(api/check-data-analyst)
|
||||
(api/check-400 (not (collections/library-collection)) "Library already exists")
|
||||
(collections/create-library-collection!))
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[macaw.core :as macaw]
|
||||
[metabase-enterprise.metabot-v3.query-analyzer.impl :as nqa.impl]
|
||||
[metabase-enterprise.metabot-v3.query-analyzer.parameter-substitution :as nqa.sub]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.util :as driver.u]
|
||||
@@ -117,7 +116,7 @@
|
||||
to return tables that are in the query."
|
||||
[driver query & {:keys [mode] :or {mode :compound-select}}]
|
||||
(let [db-id (:database query)
|
||||
macaw-opts (nqa.impl/macaw-options driver)
|
||||
macaw-opts (driver.u/macaw-options driver)
|
||||
table-opts (assoc macaw-opts :mode mode)
|
||||
sql-string (:query (nqa.sub/replace-tags query))
|
||||
result (macaw/query->tables sql-string table-opts)]
|
||||
@@ -141,7 +140,7 @@
|
||||
(defmethod tables-for-native* :sql
|
||||
[driver query opts]
|
||||
(if (or (:all-drivers-trusted? opts)
|
||||
(nqa.impl/trusted-for-table-permissions? driver))
|
||||
(driver.u/trusted-for-table-permissions? driver))
|
||||
(tables-via-macaw driver query opts)
|
||||
{:error :query-analysis.error/driver-not-supported}))
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
(ns metabase-enterprise.metabot-v3.query-analyzer.impl
|
||||
(:require
|
||||
[metabase.driver :as driver]))
|
||||
|
||||
;; NOTE: In the future we should replace [[considered-drivers]] and [[macaw-options]] with (a) driver method(s),
|
||||
;; but given that the interface with Macaw is still in a state of flux, and how simple the current configuration is,
|
||||
;; we defer extending the public interface and define the logic locally instead. Macaw itself is not battle tested
|
||||
;; outside this same narrow range of databases in any case.
|
||||
|
||||
(def ^:private considered-drivers
|
||||
"Since we are unable to ask basic questions of the driver hierarchy outside of that module, we need to explicitly
|
||||
mention all sub-types. This is probably not a bad thing."
|
||||
#{:h2 :mysql :postgres :redshift :sqlite :sqlserver})
|
||||
|
||||
;; At some point, we may want a way for 3rd party drivers to opt in, but a public API deserves some hammock time.
|
||||
;; Using a separate list to the above, as we may want this to be more restrictive.
|
||||
(def trusted-for-table-permissions?
|
||||
"Do we trust that Macaw will not give us false negatives for tables referenced by a given query?"
|
||||
#{:h2 :mysql :postgres})
|
||||
|
||||
(defn macaw-options
|
||||
"Generate the options expected by Macaw based on the nature of the given driver."
|
||||
[driver]
|
||||
;; If this isn't a driver we've considered, fallback to Macaw's conservative defaults.
|
||||
(when (contains? considered-drivers driver)
|
||||
;; According to the SQL-92 specification, non-quoted identifiers should be case-insensitive, and the majority of
|
||||
;; engines are implemented this way.
|
||||
;;
|
||||
;; In practice there are exceptions, notably MySQL and SQL Server, where case sensitivity is a property of the
|
||||
;; underlying resource referenced by the identifier, and the case-sensitivity does not depend on whether the
|
||||
;; reference is quoted.
|
||||
;;
|
||||
;; For MySQL the case sensitivity of databases and tables depends on both the underlying file system, and a system
|
||||
;; variable used to initialize the database. For SQL Server it depends on the collation settings of the collection
|
||||
;; where the corresponding schema element is defined.
|
||||
;;
|
||||
;; For MySQL, columns and aliases can never be case-sensitive, and for SQL Server the default collation is case-
|
||||
;; insensitive too, so it makes sense to just treat all databases as case-insensitive as a whole.
|
||||
;;
|
||||
;; In future, Macaw may support discriminating on the identifier type, in which case we could be more precise for
|
||||
;; these databases. Being 100% correct would require querying system variables and schema configuration however,
|
||||
;; which is likely a step too far in complexity.
|
||||
;;
|
||||
;; Currently, we go with :agnostic, as it is the most relaxed semantics (the case of both the identifiers and the
|
||||
;; underlying schema is totally ignored, and correspondence is non-deterministic), but Macaw supports more nuanced
|
||||
;; :lower and :upper configuration values which coerce the query identifiers to a given case then do an exact
|
||||
;; comparison with the schema.
|
||||
{:case-insensitive :agnostic
|
||||
;; For both MySQL and SQL Server, whether identifiers are case-sensitive depends on database configuration only,
|
||||
;; and quoting has no effect on this, so we disable this option for consistency with `:case-insensitive`.
|
||||
:quotes-preserve-case? (not (contains? #{:mysql :sqlserver} driver))
|
||||
:features {:postgres-syntax (isa? driver/hierarchy driver :postgres)
|
||||
:square-bracket-quotes (= :sqlserver driver)
|
||||
:unsupported-statements false
|
||||
:backslash-escape-char true
|
||||
;; This will slow things down, but until we measure the difference, opt for correctness.
|
||||
:complex-parsing true}
|
||||
;; 10 seconds
|
||||
:timeout 10000
|
||||
;; There is no plan to be exhaustive yet.
|
||||
;; Note that while an allowed list would be more conservative, at the time of writing only 2 of the bundled
|
||||
;; drivers use FINAL as a reserved word, and mentioning them all would be prohibitive.
|
||||
;; In the future, we will use multimethods to define this explicitly per driver, or even discover it automatically
|
||||
;; through the JDBC connection, where possible.
|
||||
:non-reserved-words (vec (remove nil? [(when-not (contains? #{:snowflake :oracle} driver)
|
||||
:final)]))}))
|
||||
@@ -51,7 +51,7 @@
|
||||
to the remote sync source."
|
||||
[]
|
||||
(api/check-superuser)
|
||||
{:is_dirty (remote-sync.object/dirty-global?)})
|
||||
{:is_dirty (remote-sync.object/dirty?)})
|
||||
|
||||
(api.macros/defendpoint :get "/has-remote-changes" :- remote-sync.schema/HasRemoteChangesResponse
|
||||
"Check if there are new changes on the remote branch that can be pulled.
|
||||
@@ -80,7 +80,7 @@
|
||||
(api/check-superuser)
|
||||
{:dirty (into []
|
||||
(m/distinct-by (juxt :id :model))
|
||||
(remote-sync.object/dirty-for-global))})
|
||||
(remote-sync.object/dirty-objects))})
|
||||
|
||||
(api.macros/defendpoint :post "/export" :- remote-sync.schema/ExportResponse
|
||||
"Export the current state of the Remote Sync collection to a Source.
|
||||
@@ -138,9 +138,11 @@
|
||||
[:remote-sync-token {:optional true} [:maybe :string]]
|
||||
[:remote-sync-type {:optional true} [:maybe [:enum :read-only :read-write]]]
|
||||
[:remote-sync-branch {:optional true} [:maybe :string]]
|
||||
[:remote-sync-auto-import {:optional true} [:maybe :boolean]]
|
||||
[:remote-sync-transforms {:optional true} [:maybe :boolean]]
|
||||
[:collections {:optional true} [:maybe [:map-of pos-int? :boolean]]]]]
|
||||
(api/check-superuser)
|
||||
(api/check-400 (not (and (remote-sync.object/dirty-global?) (= :read-only remote-sync-type)))
|
||||
(api/check-400 (not (and (remote-sync.object/dirty?) (= :read-only remote-sync-type)))
|
||||
"There are unsaved changes in the Remote Sync collection which will be overwritten switching to read-only mode.")
|
||||
;; Check if trying to change collections while in read-only mode
|
||||
(let [effective-type (or remote-sync-type (settings/remote-sync-type))]
|
||||
|
||||
@@ -53,6 +53,18 @@
|
||||
(not (collections/remote-synced-collection? (or (:collection table)
|
||||
(:collection_id table))))))
|
||||
|
||||
(defenterprise transforms-editable?
|
||||
"Determines if transforms should be editable.
|
||||
|
||||
Returns true if transforms are editable, false otherwise. Transforms are globally
|
||||
read-only when remote-sync is enabled and remote-sync-type is :read-only.
|
||||
|
||||
Always returns true on OSS."
|
||||
:feature :none
|
||||
[]
|
||||
(or (not (settings/remote-sync-enabled))
|
||||
(= (settings/remote-sync-type) :read-write)))
|
||||
|
||||
(mu/defn bulk-set-remote-sync :- :nil
|
||||
"Sets remote sync to true/false on one or collections in a single transaction. Checks that the remote sync state
|
||||
afterwards is consistent in terms of dependency rules. Collections are provided as a map of collection-id -> sync state."
|
||||
|
||||
@@ -6,17 +6,18 @@
|
||||
This enables the remote sync system to know which objects have changed
|
||||
since the last sync operation.
|
||||
|
||||
Handlers are defined using the `defmodel-change-handler` macro which:
|
||||
- Derives event hierarchies for create/update/delete events
|
||||
- Checks if the model is in a remote-synced collection
|
||||
- Creates or updates RemoteSyncObject entries with denormalized model details
|
||||
Event handlers are registered using specs from `metabase-enterprise.remote-sync.spec`.
|
||||
Each spec defines eligibility checking, field hydration, and other configuration.
|
||||
|
||||
Tracked model types:
|
||||
- Card, Dashboard, Document, NativeQuerySnippet, Timeline, Collection
|
||||
- Table (when published in a remote-synced collection)
|
||||
- Field, Segment (when belonging to a published table in a remote-synced collection)"
|
||||
- Field, Segment (when belonging to a published table in a remote-synced collection)
|
||||
- Transform, TransformTag, transforms-namespace Collections (when remote-sync-transforms setting is enabled)"
|
||||
(:require
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.remote-sync.settings :as settings]
|
||||
[metabase-enterprise.remote-sync.spec :as spec]
|
||||
[metabase.collections.core :as collections]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.util.log :as log]
|
||||
@@ -25,13 +26,6 @@
|
||||
|
||||
;; Helper functions for model change tracking
|
||||
|
||||
(defn- model-in-remote-synced-collection?
|
||||
"Checks if a model (card, dashboard, document) is in a remote-synced collection. Takes a model instance with a
|
||||
collection_id field. Returns true if the model is in a remote-synced collection, false otherwise."
|
||||
[{:keys [collection_id]}]
|
||||
(boolean
|
||||
(collections/remote-synced-collection? collection_id)))
|
||||
|
||||
(defn- create-or-update-remote-sync-object-entry!
|
||||
"Creates or updates a remote sync object entry for a model change.
|
||||
|
||||
@@ -79,92 +73,100 @@
|
||||
:model_table_id (:table_id model-details)
|
||||
:model_table_name (:table_name model-details)})))))
|
||||
|
||||
;; Model change tracking event handlers
|
||||
;;; ----------------------------------------- Spec-based Event Handling ------------------------------------------------
|
||||
|
||||
(defmacro ^:private defmodel-change-handler
|
||||
"Defines event derivations and handler for a standard model change event.
|
||||
(defn- create-or-update-sync-object-from-spec!
|
||||
"Creates or updates a RemoteSyncObject entry using a spec for field hydration.
|
||||
This is the spec-based version of create-or-update-remote-sync-object-entry!."
|
||||
[model-spec model-id status]
|
||||
(let [model-type (:model-type model-spec)
|
||||
existing (t2/select-one :model/RemoteSyncObject :model_type model-type :model_id model-id)]
|
||||
(cond
|
||||
;; No existing entry - create new one
|
||||
(not existing)
|
||||
(let [model-details (spec/hydrate-model-details model-spec model-id)
|
||||
fields (spec/build-sync-object-fields model-spec model-details)]
|
||||
(t2/insert! :model/RemoteSyncObject
|
||||
(merge {:model_type model-type
|
||||
:model_id model-id
|
||||
:status status
|
||||
:status_changed_at (t/offset-date-time)}
|
||||
fields)))
|
||||
|
||||
Usage:
|
||||
(defmodel-change-handler card
|
||||
{:model-type \"Card\"
|
||||
:event-prefix :event/card
|
||||
:log-name \"card\"
|
||||
:hydrate-details (fn [id] (t2/select-one [:model/Card :name :collection_id :display] :id id))})
|
||||
;; Created then removed/deleted before sync - delete the entry
|
||||
(and (= "create" (:status existing)) (contains? #{"removed" "delete"} status))
|
||||
(t2/delete! :model/RemoteSyncObject (:id existing))
|
||||
|
||||
Configuration options:
|
||||
- :model-type - String for RemoteSyncObject model_type (e.g. \"Card\")
|
||||
- :event-prefix - Keyword prefix for events (e.g. :event/card)
|
||||
- :log-name - String for log messages (e.g. \"card\")
|
||||
- :hydrate-details - Function (fn [model-id]) that returns map with :name, :collection_id,
|
||||
and optionally :display, :table_id, :table_name
|
||||
- :archived-key - Key to check for archived status (default :archived)
|
||||
- :in-sync-pred - Predicate fn (default model-in-remote-synced-collection?)"
|
||||
[event-group {:keys [model-type event-prefix log-name hydrate-details archived-key in-sync-pred]
|
||||
:or {archived-key :archived
|
||||
in-sync-pred `model-in-remote-synced-collection?}}]
|
||||
(let [parent-kw (keyword (str *ns*) (str (name event-group) "-change-event"))
|
||||
create-kw (keyword (namespace event-prefix) (str (name event-prefix) "-create"))
|
||||
update-kw (keyword (namespace event-prefix) (str (name event-prefix) "-update"))
|
||||
delete-kw (keyword (namespace event-prefix) (str (name event-prefix) "-delete"))]
|
||||
`(do
|
||||
(derive ~parent-kw :metabase/event)
|
||||
(derive ~create-kw ~parent-kw)
|
||||
(derive ~update-kw ~parent-kw)
|
||||
(derive ~delete-kw ~parent-kw)
|
||||
;; Already deleted - just update status
|
||||
(= "delete" (:status existing))
|
||||
(t2/update! :model/RemoteSyncObject (:id existing)
|
||||
{:status status
|
||||
:status_changed_at (t/offset-date-time)})
|
||||
|
||||
(methodical/defmethod events/publish-event! ~parent-kw
|
||||
[topic# event#]
|
||||
(let [{:keys [~'object]} event#
|
||||
in-remote-synced?# (~in-sync-pred ~'object)
|
||||
existing-entry# (t2/select-one :model/RemoteSyncObject
|
||||
:model_type ~model-type
|
||||
:model_id (:id ~'object))
|
||||
status# (if (get ~'object ~archived-key)
|
||||
"delete"
|
||||
(case topic#
|
||||
~create-kw "create"
|
||||
~update-kw "update"
|
||||
~delete-kw "delete"))]
|
||||
(cond
|
||||
in-remote-synced?#
|
||||
(do
|
||||
(log/infof "Creating remote sync object entry for %s %s (status: %s)"
|
||||
~log-name (:id ~'object) status#)
|
||||
(create-or-update-remote-sync-object-entry!
|
||||
~model-type (:id ~'object) status# ~hydrate-details))
|
||||
;; Status is not "create" - update with new details
|
||||
(not= "create" (:status existing))
|
||||
(let [model-details (spec/hydrate-model-details model-spec model-id)
|
||||
fields (spec/build-sync-object-fields model-spec model-details)]
|
||||
(t2/update! :model/RemoteSyncObject (:id existing)
|
||||
(merge {:status status
|
||||
:status_changed_at (t/offset-date-time)}
|
||||
fields))))))
|
||||
|
||||
(and existing-entry# (not in-remote-synced?#))
|
||||
(do
|
||||
(log/infof "%s %s moved out of remote-synced collection, marking as removed"
|
||||
~log-name (:id ~'object))
|
||||
(create-or-update-remote-sync-object-entry!
|
||||
~model-type (:id ~'object) "removed" ~hydrate-details))))))))
|
||||
(defn- handle-model-event-from-spec
|
||||
"Generic event handler that uses a spec for all configuration.
|
||||
Checks eligibility, determines status, and creates/updates the sync object."
|
||||
[model-spec topic {:keys [object]}]
|
||||
(let [model-type (:model-type model-spec)
|
||||
model-id (:id object)
|
||||
eligible? (spec/check-eligibility model-spec object)
|
||||
existing-entry (t2/select-one :model/RemoteSyncObject :model_type model-type :model_id model-id)
|
||||
status (spec/determine-status model-spec topic object)]
|
||||
(cond
|
||||
;; Model is eligible for sync
|
||||
eligible?
|
||||
(do
|
||||
(log/infof "Creating remote sync object entry for %s %s (status: %s)"
|
||||
model-type model-id status)
|
||||
(create-or-update-sync-object-from-spec! model-spec model-id status))
|
||||
|
||||
;; Standard model change handlers
|
||||
;; Model was synced but no longer eligible - mark as removed
|
||||
(and existing-entry (not eligible?))
|
||||
(do
|
||||
(log/infof "%s %s moved out of sync scope, marking as removed" model-type model-id)
|
||||
(create-or-update-sync-object-from-spec! model-spec model-id "removed")))))
|
||||
|
||||
(defmodel-change-handler :card
|
||||
{:model-type "Card"
|
||||
:event-prefix :event/card
|
||||
:log-name "card"
|
||||
:hydrate-details (fn [id] (t2/select-one [:model/Card :name :collection_id :display] :id id))})
|
||||
(defn- register-events-for-spec!
|
||||
"Registers event handlers for a single spec. Creates event hierarchy and
|
||||
registers a methodical handler for the parent event."
|
||||
[model-spec]
|
||||
(let [event-kws (spec/event-keywords model-spec)
|
||||
parent-kw (:parent event-kws)]
|
||||
;; Derive event hierarchy
|
||||
(derive parent-kw :metabase/event)
|
||||
(doseq [[_event-type event-kw] (dissoc event-kws :parent)]
|
||||
(derive event-kw parent-kw))
|
||||
|
||||
(defmodel-change-handler :dashboard
|
||||
{:model-type "Dashboard"
|
||||
:event-prefix :event/dashboard
|
||||
:log-name "dashboard"
|
||||
:hydrate-details (fn [id] (t2/select-one [:model/Dashboard :name :collection_id] :id id))})
|
||||
;; Register the handler using methodical's runtime API
|
||||
;; (defmethod requires compile-time dispatch values, so we use add-primary-method!)
|
||||
;; Note: We use #'events/publish-event! (the var) because add-primary-method! expects a var/atom
|
||||
;; The method signature matches defmethod: [topic event], not [next-method topic event]
|
||||
(methodical/add-primary-method!
|
||||
#'events/publish-event!
|
||||
parent-kw
|
||||
(fn [topic event]
|
||||
(handle-model-event-from-spec model-spec topic event)))))
|
||||
|
||||
(defmodel-change-handler :document
|
||||
{:model-type "Document"
|
||||
:event-prefix :event/document
|
||||
:log-name "document"
|
||||
:hydrate-details (fn [id] (t2/select-one [:model/Document :name :collection_id] :id id))})
|
||||
;;; --------------------------------- Spec-based Event Registration (Non-Collection) -----------------------------------
|
||||
;; Register event handlers for models that use standard spec-based handling.
|
||||
;; Collection has special handling and is registered separately below.
|
||||
|
||||
(defmodel-change-handler :snippet
|
||||
{:model-type "NativeQuerySnippet"
|
||||
:event-prefix :event/snippet
|
||||
:log-name "snippet"
|
||||
:hydrate-details (fn [id] (t2/select-one [:model/NativeQuerySnippet :name :id] :id id))})
|
||||
(doseq [[_model-key model-spec] (dissoc spec/remote-sync-specs :model/Collection)]
|
||||
(register-events-for-spec! model-spec))
|
||||
|
||||
;;; ----------------------------------------- Collection Event Handler -------------------------------------------------
|
||||
;; Collection has special handling due to side effects (tracking published tables when
|
||||
;; a collection becomes remote-synced). This handler is kept separate from the standard
|
||||
;; spec-based registration.
|
||||
|
||||
;; Collection create/update events - derive from common parent for shared handling
|
||||
(derive ::collection-change-event :metabase/event)
|
||||
@@ -176,6 +178,18 @@
|
||||
[id]
|
||||
(t2/select-one [:model/Collection :name [:id :collection_id]] :id id))
|
||||
|
||||
(defn- transforms-namespace-collection?
|
||||
"Check if this is a transforms-namespace collection."
|
||||
[collection]
|
||||
(= (keyword (:namespace collection)) :transforms))
|
||||
|
||||
(defn- should-sync-collection?
|
||||
"Check if a collection should be synced - either remote-synced or transforms-namespace with setting enabled."
|
||||
[collection]
|
||||
(or (collections/remote-synced-collection? collection)
|
||||
(and (settings/remote-sync-transforms)
|
||||
(transforms-namespace-collection? collection))))
|
||||
|
||||
(defn- hydrate-table-details
|
||||
"Hydrates details for a Table. For tables, table_id and table_name refer to themselves."
|
||||
[id]
|
||||
@@ -203,96 +217,36 @@
|
||||
(methodical/defmethod events/publish-event! ::collection-change-event
|
||||
[topic event]
|
||||
(let [{:keys [object]} event
|
||||
should-sync? (should-sync-collection? object)
|
||||
is-remote-synced? (collections/remote-synced-collection? object)
|
||||
existing-entry (t2/select-one :model/RemoteSyncObject :model_type "Collection" :model_id (:id object))
|
||||
was-remote-synced? (and existing-entry
|
||||
(not (contains? #{"removed"} (:status existing-entry))))
|
||||
was-synced? (and existing-entry
|
||||
(not (contains? #{"removed"} (:status existing-entry))))
|
||||
status (if (:archived object)
|
||||
"delete"
|
||||
(case topic
|
||||
:event/collection-create "create"
|
||||
:event/collection-update "update"))]
|
||||
(cond
|
||||
;; Collection is remote-synced - create or update entry
|
||||
is-remote-synced?
|
||||
;; Collection should be synced (remote-synced or transforms-namespace with setting enabled)
|
||||
should-sync?
|
||||
(do
|
||||
(log/infof "Creating remote sync object entry for collection %s (status: %s)" (:id object) status)
|
||||
(create-or-update-remote-sync-object-entry! "Collection" (:id object) status hydrate-collection-details)
|
||||
;; If collection just became remote-synced, track all published tables in it
|
||||
(when (not was-remote-synced?)
|
||||
(track-published-tables-in-collection! (:id object))))
|
||||
;; (only for actual remote-synced collections, not transforms-namespace)
|
||||
(when (and is-remote-synced? (not was-synced?))
|
||||
(track-published-tables-in-collection! (:id object)))
|
||||
;; When a transforms-namespace collection is archived, also mark child transforms for deletion
|
||||
(when (and (:archived object) (transforms-namespace-collection? object))
|
||||
(let [transform-ids (t2/select-pks-set :model/Transform :collection_id (:id object))]
|
||||
(doseq [transform-id transform-ids]
|
||||
(log/infof "Marking transform %s for deletion (parent collection archived)" transform-id)
|
||||
(create-or-update-remote-sync-object-entry! "Transform" transform-id "delete"
|
||||
(fn [id] (t2/select-one [:model/Transform :name [:collection_id :model_collection_id]] :id id)))))))
|
||||
|
||||
;; Collection was remote-synced but type changed - mark as removed
|
||||
(and existing-entry (not is-remote-synced?))
|
||||
;; Collection was synced but no longer should be - mark as removed
|
||||
(and existing-entry (not should-sync?))
|
||||
(do
|
||||
(log/infof "Collection %s type changed from remote-synced, marking as removed" (:id object))
|
||||
(log/infof "Collection %s no longer needs syncing, marking as removed" (:id object))
|
||||
(create-or-update-remote-sync-object-entry! "Collection" (:id object) "removed" hydrate-collection-details)))))
|
||||
|
||||
(defmodel-change-handler :timeline
|
||||
{:model-type "Timeline"
|
||||
:event-prefix :event/timeline
|
||||
:log-name "timeline"
|
||||
:hydrate-details (fn [id] (t2/select-one [:model/Timeline :name :collection_id] :id id))})
|
||||
|
||||
;; Table events - only track published tables in remote-synced collections
|
||||
|
||||
(defn- published-table-in-remote-synced-collection?
|
||||
"Check if a table is published AND in a remote-synced collection.
|
||||
Tables are only considered part of remote sync when they are both published
|
||||
and their collection_id points to a remote-synced collection."
|
||||
[{:keys [is_published collection_id]}]
|
||||
(boolean
|
||||
(and is_published
|
||||
(collections/remote-synced-collection? collection_id))))
|
||||
|
||||
(defn- model-in-published-table-in-remote-synced-collection?
|
||||
"Check if a model (field, segment) belongs to a published table in a remote-synced collection.
|
||||
The model must have a :table_id that points to a table with :is_published true
|
||||
and :collection_id in a remote-synced collection."
|
||||
[{:keys [table_id]}]
|
||||
(boolean
|
||||
(when table_id
|
||||
(when-let [table (t2/select-one :model/Table :id table_id)]
|
||||
(published-table-in-remote-synced-collection? table)))))
|
||||
|
||||
(defmodel-change-handler :table
|
||||
{:model-type "Table"
|
||||
:event-prefix :event/table
|
||||
:log-name "table"
|
||||
:archived-key :archived_at
|
||||
:in-sync-pred published-table-in-remote-synced-collection?
|
||||
:hydrate-details hydrate-table-details})
|
||||
|
||||
;; Segment events - track segments in published tables in remote-synced collections
|
||||
|
||||
(defn- hydrate-segment-details
|
||||
"Hydrates details for a Segment, including parent table info."
|
||||
[id]
|
||||
(first (t2/query {:select [:s.name :s.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
|
||||
:from [[:segment :s]]
|
||||
:join [[:metabase_table :t] [:= :s.table_id :t.id]]
|
||||
:where [:= :s.id id]})))
|
||||
|
||||
(defmodel-change-handler :segment
|
||||
{:model-type "Segment"
|
||||
:event-prefix :event/segment
|
||||
:log-name "segment"
|
||||
:in-sync-pred model-in-published-table-in-remote-synced-collection?
|
||||
:hydrate-details hydrate-segment-details})
|
||||
|
||||
;; Field events - track fields in published tables in remote-synced collections
|
||||
|
||||
(defn- hydrate-field-details
|
||||
"Hydrates details for a Field, including parent table info."
|
||||
[id]
|
||||
(first (t2/query {:select [:f.name :f.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
|
||||
:from [[:metabase_field :f]]
|
||||
:join [[:metabase_table :t] [:= :f.table_id :t.id]]
|
||||
:where [:= :f.id id]})))
|
||||
|
||||
(defmodel-change-handler :field
|
||||
{:model-type "Field"
|
||||
:event-prefix :event/field
|
||||
:log-name "field"
|
||||
:in-sync-pred model-in-published-table-in-remote-synced-collection?
|
||||
:hydrate-details hydrate-field-details})
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[metabase-enterprise.remote-sync.source :as source]
|
||||
[metabase-enterprise.remote-sync.source.ingestable :as source.ingestable]
|
||||
[metabase-enterprise.remote-sync.source.protocol :as source.p]
|
||||
[metabase-enterprise.remote-sync.spec :as spec]
|
||||
[metabase-enterprise.serialization.core :as serialization]
|
||||
[metabase.analytics.core :as analytics]
|
||||
[metabase.api.common :as api]
|
||||
@@ -27,105 +28,17 @@
|
||||
[]
|
||||
(t2/select-pks-vec :model/Collection :is_remote_synced true))
|
||||
|
||||
(defn- model-fields-for-sync
|
||||
"Returns the fields to select for a given model type when syncing."
|
||||
[model-name]
|
||||
(case model-name
|
||||
"Card" [:id :name :collection_id :display]
|
||||
"NativeQuerySnippet" [:id :name]
|
||||
"Collection" [:id :name [:id :collection_id]]
|
||||
"Segment" [:id :name :table_id]
|
||||
[:id :name :collection_id]))
|
||||
|
||||
(defn- sync-objects!
|
||||
"Populates the remote-sync-object table with imported entities. Deletes all existing RemoteSyncObject records and
|
||||
inserts new ones for each imported entity, marking them as 'synced' with the given timestamp.
|
||||
|
||||
Takes a timestamp instant, a map of imported entities grouped by model name (for standard models with entity_id),
|
||||
and separate path collections for Table and Field models which use name-based identity."
|
||||
and separate path collections for Table and Field models which use name-based identity.
|
||||
|
||||
Uses the spec system to determine how to query and build sync objects for each model type."
|
||||
[timestamp imported-entities-by-model table-paths field-paths]
|
||||
(t2/delete! :model/RemoteSyncObject)
|
||||
(let [;; Standard models use entity_id UUIDs (excluding Segment which needs special handling)
|
||||
;; TODO(edpaget 2026-01-19): Actions are not dirty tracked
|
||||
standard-inserts (->> (dissoc imported-entities-by-model "Segment" "Action")
|
||||
(mapcat (fn [[model entity-ids]]
|
||||
(when (seq entity-ids)
|
||||
(let [fields (model-fields-for-sync model)
|
||||
model-kw (keyword "model" model)]
|
||||
(t2/select (into [model-kw] fields) :entity_id [:in entity-ids])))))
|
||||
(map (fn [{:keys [id name collection_id display] :as model}]
|
||||
{:model_type (clojure.core/name (t2/model model))
|
||||
:model_id id
|
||||
:model_name name
|
||||
:model_collection_id collection_id
|
||||
:model_display (some-> display clojure.core/name)
|
||||
:model_table_id nil
|
||||
:model_table_name nil
|
||||
:status "synced"
|
||||
:status_changed_at timestamp})))
|
||||
;; Segments: need to join to get table info
|
||||
segment-entity-ids (get imported-entities-by-model "Segment")
|
||||
segment-inserts (when (seq segment-entity-ids)
|
||||
(->> (t2/query {:select [:s.id :s.name :s.table_id [:t.name :table_name] [:t.collection_id :collection_id]]
|
||||
:from [[:segment :s]]
|
||||
:join [[:metabase_table :t] [:= :s.table_id :t.id]]
|
||||
:where [:in :s.entity_id segment-entity-ids]})
|
||||
(map (fn [{:keys [id name table_id table_name collection_id]}]
|
||||
{:model_type "Segment"
|
||||
:model_id id
|
||||
:model_name name
|
||||
:model_collection_id collection_id
|
||||
:model_display nil
|
||||
:model_table_id table_id
|
||||
:model_table_name table_name
|
||||
:status "synced"
|
||||
:status_changed_at timestamp}))))
|
||||
;; Tables: batch lookup using join - include name and collection_id
|
||||
;; For tables, table_id = self id, table_name = self name
|
||||
table-inserts (when (seq table-paths)
|
||||
(->> (t2/query {:select [:t.id :t.name :t.collection_id]
|
||||
:from [[:metabase_table :t]]
|
||||
:join [[:metabase_database :db] [:= :db.id :t.db_id]]
|
||||
:where (into [:or]
|
||||
(for [{:keys [db_name schema table_name]} table-paths]
|
||||
[:and
|
||||
[:= :db.name db_name]
|
||||
(if schema [:= :t.schema schema] [:is :t.schema nil])
|
||||
[:= :t.name table_name]]))})
|
||||
(map (fn [{:keys [id name collection_id]}]
|
||||
{:model_type "Table"
|
||||
:model_id id
|
||||
:model_name name
|
||||
:model_collection_id collection_id
|
||||
:model_display nil
|
||||
:model_table_id id
|
||||
:model_table_name name
|
||||
:status "synced"
|
||||
:status_changed_at timestamp}))))
|
||||
;; Fields: batch lookup using join through table - include name and collection_id from table
|
||||
field-inserts (when (seq field-paths)
|
||||
(->> (t2/query {:select [:f.id :f.name :f.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
|
||||
:from [[:metabase_field :f]]
|
||||
:join [[:metabase_table :t] [:= :t.id :f.table_id]
|
||||
[:metabase_database :db] [:= :db.id :t.db_id]]
|
||||
:where (into [:or]
|
||||
(for [{:keys [db_name schema table_name field_name]} field-paths]
|
||||
[:and
|
||||
[:= :db.name db_name]
|
||||
(if schema [:= :t.schema schema] [:is :t.schema nil])
|
||||
[:= :t.name table_name]
|
||||
[:= :f.name field_name]]))})
|
||||
(map (fn [{:keys [id name table_id table_name collection_id]}]
|
||||
{:model_type "Field"
|
||||
:model_id id
|
||||
:model_name name
|
||||
:model_collection_id collection_id
|
||||
:model_display nil
|
||||
:model_table_id table_id
|
||||
:model_table_name table_name
|
||||
:status "synced"
|
||||
:status_changed_at timestamp}))))
|
||||
all-inserts (concat standard-inserts segment-inserts table-inserts field-inserts)]
|
||||
(let [all-inserts (spec/sync-all-entities! timestamp imported-entities-by-model table-paths field-paths)]
|
||||
(when (seq all-inserts)
|
||||
(t2/insert! :model/RemoteSyncObject all-inserts))))
|
||||
|
||||
@@ -133,30 +46,28 @@
|
||||
"Deletes any remote sync content that was NOT part of the import.
|
||||
|
||||
Takes a sequence of remote-synced collection IDs and a map of imported entities grouped by model name. For each
|
||||
model type (Collection, Card, Dashboard, etc.), deletes any entities in the remote-synced collections whose
|
||||
entity_id is not in the imported set."
|
||||
entity-id based model, deletes entities whose entity_id is not in the imported set.
|
||||
|
||||
Models with :scope-key in their spec are scoped to synced collections (using :id for Collection, :collection_id
|
||||
for others). Models without :scope-key (like TransformTag) are deleted globally by entity_id."
|
||||
[synced-collection-ids imported-entities-by-model]
|
||||
(when (seq synced-collection-ids)
|
||||
(doseq [model [:model/Collection
|
||||
:model/Card
|
||||
:model/Dashboard
|
||||
:model/NativeQuerySnippet
|
||||
:model/Timeline
|
||||
:model/Document]
|
||||
:let [serdes-model (name model)
|
||||
entity-ids (get imported-entities-by-model serdes-model [])]]
|
||||
(if (= model :model/Collection)
|
||||
(t2/delete! :model/Collection
|
||||
:id [:in synced-collection-ids]
|
||||
;; if we didn't sync any, then delete all collections in the remote sync
|
||||
:entity_id (if (seq entity-ids)
|
||||
[:not-in entity-ids]
|
||||
:entity_id))
|
||||
(t2/delete! model
|
||||
:collection_id [:in synced-collection-ids]
|
||||
:entity_id (if (seq entity-ids)
|
||||
[:not-in entity-ids]
|
||||
:entity_id))))))
|
||||
(doseq [[model-key model-spec] (spec/specs-by-identity-type :entity-id)
|
||||
:let [serdes-model (:model-type model-spec)
|
||||
entity-ids (get imported-entities-by-model serdes-model [])
|
||||
entity-id-clause (if (seq entity-ids)
|
||||
[:not-in entity-ids]
|
||||
:entity_id)
|
||||
;; :scope-key determines collection scoping - nil means global
|
||||
scope-key (get-in model-spec [:removal :scope-key])]]
|
||||
(if scope-key
|
||||
;; Collection-scoped: delete only within synced collections
|
||||
(when (seq synced-collection-ids)
|
||||
(t2/delete! model-key
|
||||
scope-key [:in synced-collection-ids]
|
||||
:entity_id entity-id-clause))
|
||||
;; Global: delete by entity_id only
|
||||
(t2/delete! model-key
|
||||
:entity_id entity-id-clause))))
|
||||
|
||||
(defn source-error-message
|
||||
"Constructs user-friendly error messages from remote sync source exceptions.
|
||||
@@ -181,6 +92,9 @@
|
||||
(some-> e ex-cause ex-message (str/includes? "Can't create a tenant collection without tenants enabled"))
|
||||
"This repository contains tenant collections, but the tenants feature is disabled on your instance."
|
||||
|
||||
(str/includes? (ex-message e) "Missing commit")
|
||||
"Repository cache is stale: the remote repository may have been force-pushed. Please retry the operation."
|
||||
|
||||
:else
|
||||
(format "Failed to reload from git repository: %s" (ex-message e))))
|
||||
|
||||
@@ -226,7 +140,10 @@
|
||||
:version (source.p/version snapshot)
|
||||
:message (format "Skipping import: snapshot version %s matches last imported version" snapshot-version)}
|
||||
(log/infof (:message <>)))
|
||||
(let [ingestable-snapshot (->> (source.p/->ingestable snapshot {:path-filters [#"collections/.*" #"databases/.*" #"actions/.*"]})
|
||||
(let [path-filters (cond-> [#"collections/.*" #"databases/.*" #"actions/.*"]
|
||||
(settings/remote-sync-transforms)
|
||||
(conj #"transforms/.*"))
|
||||
ingestable-snapshot (->> (source.p/->ingestable snapshot {:path-filters path-filters})
|
||||
(source.ingestable/wrap-progress-ingestable task-id 0.7))
|
||||
load-result (serdes/with-cache
|
||||
(serialization/load-metabase! ingestable-snapshot))
|
||||
@@ -290,90 +207,33 @@
|
||||
exceptions may be thrown during export and are caught and converted to error status maps."
|
||||
[^SourceSnapshot snapshot task-id message]
|
||||
(if snapshot
|
||||
(let [sync-timestamp (t/instant)
|
||||
collections (t2/select-fn-set :entity_id :model/Collection :is_remote_synced true :location "/")]
|
||||
(if (empty? collections)
|
||||
{:status :error
|
||||
:message "No remote-synced collections available to sync."}
|
||||
(try
|
||||
(analytics/inc! :metabase-remote-sync/exports)
|
||||
(serdes/with-cache
|
||||
(let [models (serialization/extract {:targets (mapv #(vector "Collection" %) collections)
|
||||
:no-collections false
|
||||
:no-data-model true
|
||||
:no-settings true
|
||||
:no-transforms true
|
||||
:include-field-values false
|
||||
:include-database-secrets false
|
||||
:continue-on-error false
|
||||
:skip-archived true})]
|
||||
(let [sync-timestamp (t/instant)]
|
||||
(try
|
||||
(analytics/inc! :metabase-remote-sync/exports)
|
||||
(serdes/with-cache
|
||||
(if-let [models (spec/extract-entities-for-export)]
|
||||
(do
|
||||
(remote-sync.task/update-progress! task-id 0.3)
|
||||
(let [top-level-removed-prefixes (->> (t2/query {:select [:c.entity_id]
|
||||
:from [[:collection :c]]
|
||||
:join [[:remote_sync_object :rso]
|
||||
[:and [:= :rso.model_type [:inline "Collection"]]
|
||||
[:= :rso.status [:inline "removed"]]
|
||||
[:= :rso.model_id :c.id]]]
|
||||
:where [:= :location "/"]})
|
||||
(map #(str "collections/" (:entity_id %))))
|
||||
;; Add removed table paths - join to get db_name + schema in one query
|
||||
removed-table-paths (->> (t2/query {:select [[:t.name :table_name] :t.schema [:db.name :db_name]]
|
||||
:from [[:metabase_table :t]]
|
||||
:join [[:remote_sync_object :rso]
|
||||
[:and [:= :rso.model_type [:inline "Table"]]
|
||||
[:= :rso.status [:inline "removed"]]
|
||||
[:= :rso.model_id :t.id]]
|
||||
[:metabase_database :db]
|
||||
[:= :db.id :t.db_id]]})
|
||||
(map (fn [{:keys [table_name schema db_name]}]
|
||||
(str/join "/" (cond-> ["databases" db_name]
|
||||
schema (conj "schemas" schema)
|
||||
true (conj "tables" table_name))))))
|
||||
;; Add removed/deleted segment paths - join to get all path components in one query
|
||||
;; Segments use entity_id for path construction
|
||||
;; Include both "removed" (moved out of remote-synced collection) and "delete" (archived)
|
||||
removed-segment-paths (->> (t2/query {:select [:s.entity_id
|
||||
[:t.name :table_name]
|
||||
:t.schema
|
||||
[:db.name :db_name]]
|
||||
:from [[:segment :s]]
|
||||
:join [[:remote_sync_object :rso]
|
||||
[:and [:= :rso.model_type [:inline "Segment"]]
|
||||
[:in :rso.status [[:inline "removed"] [:inline "delete"]]]
|
||||
[:= :rso.model_id :s.id]]
|
||||
[:metabase_table :t] [:= :t.id :s.table_id]
|
||||
[:metabase_database :db] [:= :db.id :t.db_id]]})
|
||||
(map (fn [{:keys [entity_id table_name schema db_name]}]
|
||||
(str/join "/" (cond-> ["databases" db_name]
|
||||
schema (conj "schemas" schema)
|
||||
true (conj "tables" table_name "segments" entity_id))))))
|
||||
all-delete-prefixes (concat top-level-removed-prefixes
|
||||
removed-table-paths
|
||||
removed-segment-paths)
|
||||
(let [all-delete-prefixes (spec/build-all-removal-paths)
|
||||
written-version (source/store! models all-delete-prefixes snapshot task-id message)]
|
||||
(remote-sync.task/set-version! task-id written-version))
|
||||
;; Delete removed Table/Field/Segment entries (they've been deleted from remote)
|
||||
(t2/delete! :model/RemoteSyncObject
|
||||
:model_type [:in ["Table" "Field" "Segment"]]
|
||||
:status "removed")
|
||||
;; Also delete archived Segment entries (status "delete")
|
||||
(t2/delete! :model/RemoteSyncObject
|
||||
:model_type "Segment"
|
||||
:status "delete")
|
||||
(t2/update! :model/RemoteSyncObject {:status "synced" :status_changed_at sync-timestamp})))
|
||||
{:status :success
|
||||
:version (source.p/version snapshot)}
|
||||
(catch Exception e
|
||||
(if (:cancelled? (ex-data e))
|
||||
(log/info "Export to git repository was cancelled")
|
||||
(do
|
||||
(analytics/inc! :metabase-remote-sync/exports-failed)
|
||||
(remote-sync.task/fail-sync-task! task-id (ex-message e))
|
||||
{:status :error
|
||||
:version (source.p/version snapshot)
|
||||
:message (format "Failed to export to git repository: %s" (ex-message e))})))
|
||||
(finally
|
||||
(analytics/observe! :metabase-remote-sync/export-duration-ms (t/as (t/duration sync-timestamp (t/instant)) :millis))))))
|
||||
(t2/update! :model/RemoteSyncObject {:status "synced" :status_changed_at sync-timestamp})
|
||||
{:status :success
|
||||
:version (source.p/version snapshot)})
|
||||
{:status :error
|
||||
:message "No remote-syncable content available."}))
|
||||
(catch Exception e
|
||||
(if (:cancelled? (ex-data e))
|
||||
(log/info "Export to git repository was cancelled")
|
||||
(do
|
||||
(log/errorf e "Failed to export to git repository: %s" (ex-message e))
|
||||
(analytics/inc! :metabase-remote-sync/exports-failed)
|
||||
(remote-sync.task/fail-sync-task! task-id (ex-message e))
|
||||
{:status :error
|
||||
:version (source.p/version snapshot)
|
||||
:message (format "Failed to export to git repository: %s" (ex-message e))})))
|
||||
(finally
|
||||
(analytics/observe! :metabase-remote-sync/export-duration-ms (t/as (t/duration sync-timestamp (t/instant)) :millis)))))
|
||||
{:status :error
|
||||
:message "Remote sync source is not enabled. Please configure MB_GIT_SOURCE_REPO_URL environment variable."}))
|
||||
|
||||
@@ -486,7 +346,14 @@
|
||||
(u.jvm/in-virtual-thread*
|
||||
(dh/with-timeout {:interrupt? true
|
||||
:timeout-ms (* (settings/remote-sync-task-time-limit-ms) 10)}
|
||||
(handle-task-result! (sync-fn task-id) task-id branch)))
|
||||
(handle-task-result!
|
||||
(try
|
||||
(sync-fn task-id)
|
||||
(catch Exception e
|
||||
(log/error e "Remote sync task failed")
|
||||
{:status :error
|
||||
:message (source-error-message e)}))
|
||||
task-id branch)))
|
||||
task))
|
||||
|
||||
(defn async-import!
|
||||
@@ -500,7 +367,7 @@
|
||||
are unsaved changes and force? is false."
|
||||
[branch force? import-args]
|
||||
(let [source (source/source-from-settings branch)
|
||||
has-dirty? (remote-sync.object/dirty-global?)]
|
||||
has-dirty? (remote-sync.object/dirty?)]
|
||||
(when (and has-dirty? (not force?))
|
||||
(throw (ex-info "There are unsaved changes in the Remote Sync collection which will be overwritten by the import. Force the import to discard these changes."
|
||||
{:status-code 400
|
||||
@@ -542,3 +409,39 @@
|
||||
(:id (async-import! (settings/remote-sync-branch) true {}))))
|
||||
(u/prog1 nil
|
||||
(collection/clear-remote-synced-collection!))))
|
||||
|
||||
(defn sync-transform-tracking!
|
||||
"Called when remote-sync-transforms setting changes.
|
||||
When enabled: mark all existing transforms, transform tags, and transforms-namespace collections
|
||||
as 'create' for initial sync.
|
||||
When disabled: remove all transform-related tracking entries."
|
||||
[enabled?]
|
||||
(let [timestamp (t/offset-date-time)]
|
||||
(if enabled?
|
||||
(do
|
||||
;; Mark all transforms-namespace collections for initial sync
|
||||
(doseq [coll (t2/select [:model/Collection :id :name] :namespace (name collection/transforms-ns))]
|
||||
(t2/insert! :model/RemoteSyncObject
|
||||
{:model_type "Collection"
|
||||
:model_id (:id coll)
|
||||
:model_name (:name coll)
|
||||
:status "create"
|
||||
:status_changed_at timestamp}))
|
||||
;; Mark all existing transforms and transform tags for initial sync
|
||||
(doseq [[model name-key] [[:model/Transform :name]
|
||||
[:model/TransformTag :name]]]
|
||||
(doseq [entity (t2/select [model :id name-key])]
|
||||
(t2/insert! :model/RemoteSyncObject
|
||||
{:model_type (name (last (str/split (name model) #"/")))
|
||||
:model_id (:id entity)
|
||||
:model_name (get entity name-key)
|
||||
:status "create"
|
||||
:status_changed_at timestamp}))))
|
||||
;; Remove all transform-related tracking (including transforms-namespace collections)
|
||||
(let [transform-coll-ids (t2/select-pks-set :model/Collection :namespace (name collection/transforms-ns))]
|
||||
(t2/delete! :model/RemoteSyncObject
|
||||
:model_type [:in ["Transform" "TransformTag"]])
|
||||
(when (seq transform-coll-ids)
|
||||
(t2/delete! :model/RemoteSyncObject
|
||||
:model_type "Collection"
|
||||
:model_id [:in transform-coll-ids]))))))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
and for Field/Segment/Table models, parent table information."
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.remote-sync.spec :as spec]
|
||||
[metabase.util :as u]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -18,27 +19,40 @@
|
||||
|
||||
;;; ------------------------------------------------- Public API -------------------------------------------------------
|
||||
|
||||
(defn dirty-global?
|
||||
(defn dirty?
|
||||
"Checks if any collection has changes since the last sync.
|
||||
Returns true if any remote-synced object has a status other than 'synced', false otherwise."
|
||||
Returns true if any remote-synced object has a status other than 'synced', false otherwise.
|
||||
Excludes transform model types when transform sync is disabled."
|
||||
[]
|
||||
(t2/exists? :model/RemoteSyncObject :status [:not= "synced"]))
|
||||
(let [excluded (spec/excluded-model-types)]
|
||||
(if (empty? excluded)
|
||||
(t2/exists? :model/RemoteSyncObject :status [:not= "synced"])
|
||||
(t2/exists? :model/RemoteSyncObject
|
||||
:status [:not= "synced"]
|
||||
:model_type [:not-in excluded]))))
|
||||
|
||||
(defn dirty-for-global
|
||||
(defn dirty-objects
|
||||
"Gets all models in any collection that are dirty with their sync status.
|
||||
Returns a sequence of model maps that have changed since the last remote sync,
|
||||
including details about their current state and sync status."
|
||||
Returns a sequence of model maps that have changed since the last remote sync,
|
||||
including details about their current state and sync status.
|
||||
Excludes transform model types when transform sync is disabled."
|
||||
[]
|
||||
(->> (t2/select :model/RemoteSyncObject :status [:not= "synced"])
|
||||
(map #(-> %
|
||||
(dissoc :id :status_changed_at)
|
||||
(set/rename-keys {:model_id :id
|
||||
:model_name :name
|
||||
:model_type :model
|
||||
:model_collection_id :collection_id
|
||||
:model_display :display
|
||||
:model_table_id :table_id
|
||||
:model_table_name :table_name
|
||||
:status :sync_status})
|
||||
(update :model u/lower-case-en)))
|
||||
(into [])))
|
||||
(let [excluded (spec/excluded-model-types)
|
||||
query (if (empty? excluded)
|
||||
(t2/select :model/RemoteSyncObject :status [:not= "synced"])
|
||||
(t2/select :model/RemoteSyncObject
|
||||
:status [:not= "synced"]
|
||||
:model_type [:not-in excluded]))]
|
||||
(->> query
|
||||
(map #(-> %
|
||||
(dissoc :id :status_changed_at)
|
||||
(set/rename-keys {:model_id :id
|
||||
:model_name :name
|
||||
:model_type :model
|
||||
:model_collection_id :collection_id
|
||||
:model_display :display
|
||||
:model_table_id :table_id
|
||||
:model_table_name :table_name
|
||||
:status :sync_status})
|
||||
(update :model u/lower-case-en)))
|
||||
(into []))))
|
||||
|
||||
@@ -86,6 +86,14 @@
|
||||
:encryption :no
|
||||
:default (* 1000 60 5))
|
||||
|
||||
(defsetting remote-sync-transforms
|
||||
(deferred-tru "Whether to sync transforms via remote-sync. When enabled, all transforms, transform tags, and transform jobs are synced as a single unit (all-or-nothing).")
|
||||
:type :boolean
|
||||
:visibility :admin
|
||||
:export? false
|
||||
:encryption :no
|
||||
:default false)
|
||||
|
||||
(defsetting remote-sync-check-changes-cache-ttl-seconds
|
||||
(deferred-tru "Time-to-live in seconds for the remote changes check cache. Default is 60 seconds.")
|
||||
:type :integer
|
||||
@@ -141,7 +149,7 @@
|
||||
token-to-check (if obfuscated? current-token remote-sync-token)
|
||||
_ (check-git-settings! (assoc settings :remote-sync-token token-to-check))]
|
||||
(t2/with-transaction [_conn]
|
||||
(doseq [k [:remote-sync-url :remote-sync-token :remote-sync-type :remote-sync-branch :remote-sync-auto-import]]
|
||||
(doseq [k [:remote-sync-url :remote-sync-token :remote-sync-type :remote-sync-branch :remote-sync-auto-import :remote-sync-transforms]]
|
||||
(when (and (contains? settings k)
|
||||
(not (and (= k :remote-sync-token) obfuscated?)))
|
||||
(setting/set! k (k settings))))))))
|
||||
|
||||
@@ -233,12 +233,15 @@
|
||||
Takes a git-source map containing a :git Git instance.
|
||||
|
||||
Returns the default branch name as a string (without 'refs/heads/' prefix), or nil if no default branch is found."
|
||||
[{:keys [^Git git]}]
|
||||
(let [repo (.getRepository git)
|
||||
head-ref (.findRef repo "HEAD")]
|
||||
(when head-ref
|
||||
(when-let [target-ref (.getTarget head-ref)]
|
||||
(str/replace-first (.getName target-ref) "refs/heads/" "")))))
|
||||
[{:keys [^Git git] :as git-source}]
|
||||
;; Query the remote directly to get HEAD - lsRemote returns symbolic refs
|
||||
(let [refs (call-remote-command (.lsRemote git) git-source)
|
||||
head-ref (first (filter #(= "HEAD" (.getName ^Ref %)) refs))]
|
||||
(or (when head-ref
|
||||
(when (.isSymbolic ^Ref head-ref)
|
||||
(when-let [target (.getTarget ^Ref head-ref)]
|
||||
(str/replace-first (.getName ^Ref target) "refs/heads/" ""))))
|
||||
(throw (ex-info "Failed to get a default branch for git repository." {:head-ref head-ref})))))
|
||||
|
||||
(defn write-files!
|
||||
"Writes multiple files to the git repository and commits the changes.
|
||||
@@ -250,9 +253,13 @@
|
||||
- :path and :content keys for writing/updating a file
|
||||
- :path and :remove? true for recursively removing all files at that path
|
||||
|
||||
Replaces all files in the branch organized by collection prefix - files not in the provided list but in the same
|
||||
collection prefix will be deleted. Removal entries with empty paths are no-ops. Removing non-existent paths
|
||||
is also a no-op (idempotent).
|
||||
For writes within collection directories, ALL files in the same collection are replaced
|
||||
(using the collection entity_id prefix to identify the collection scope). This ensures
|
||||
that stale files don't remain when a collection's contents change.
|
||||
|
||||
For removals, all files matching the path as a prefix are deleted (allowing recursive
|
||||
directory deletion). Removal entries with empty paths are no-ops. Removing non-existent
|
||||
paths is also a no-op (idempotent).
|
||||
|
||||
Returns the version written. Throws ExceptionInfo if the write or push
|
||||
operation fails."
|
||||
@@ -264,21 +271,35 @@
|
||||
(with-open [inserter (.newObjectInserter repo)]
|
||||
(let [index (DirCache/newInCore)
|
||||
builder (.builder index)
|
||||
updated-prefixes (into #{}
|
||||
;; Extract collection prefixes from written paths - all files in these
|
||||
;; collections will be deleted and replaced with the new files
|
||||
write-prefixes (into #{}
|
||||
(comp
|
||||
(remove :remove?)
|
||||
(map :path)
|
||||
(remove str/blank?)
|
||||
(map path-prefix))
|
||||
files)
|
||||
;; Collect removal paths/prefixes for explicit deletions
|
||||
removal-prefixes (into #{}
|
||||
(comp
|
||||
(map (fn [{:keys [path content remove?]}]
|
||||
(when-not remove?
|
||||
(let [blob-id (.insert inserter Constants/OBJ_BLOB (.getBytes ^String content "UTF-8"))
|
||||
entry (doto (DirCacheEntry. ^String path)
|
||||
(.setFileMode FileMode/REGULAR_FILE)
|
||||
(.setObjectId blob-id))]
|
||||
(.add builder entry)))
|
||||
(when (not-empty path)
|
||||
(path-prefix path))))
|
||||
(remove nil?))
|
||||
(filter :remove?)
|
||||
(map :path)
|
||||
(remove str/blank?))
|
||||
files)]
|
||||
|
||||
;; Copy existing tree entries, excluding files under updated-prefixes
|
||||
;; Add new/updated files to the index
|
||||
(doseq [{:keys [path content remove?]} files
|
||||
:when (and (not remove?) (not (str/blank? path)))]
|
||||
(let [blob-id (.insert inserter Constants/OBJ_BLOB (.getBytes ^String content "UTF-8"))
|
||||
entry (doto (DirCacheEntry. ^String path)
|
||||
(.setFileMode FileMode/REGULAR_FILE)
|
||||
(.setObjectId blob-id))]
|
||||
(.add builder entry)))
|
||||
|
||||
;; Copy existing tree entries, excluding:
|
||||
;; 1. Files in collections being written to (using write-prefixes)
|
||||
;; 2. Files matching explicit removal prefixes
|
||||
(when parent-id
|
||||
(with-open [rev-walk (RevWalk. repo)
|
||||
tree-walk (TreeWalk. repo)]
|
||||
@@ -286,8 +307,10 @@
|
||||
(.addTree tree-walk (.getTree commit))
|
||||
(.setRecursive tree-walk true)
|
||||
(while (.next tree-walk)
|
||||
(let [path (.getPathString tree-walk)]
|
||||
(when-not (matches-prefix path updated-prefixes)
|
||||
(let [path (.getPathString tree-walk)
|
||||
existing-prefix (path-prefix path)]
|
||||
(when-not (or (contains? write-prefixes existing-prefix)
|
||||
(matches-prefix path removal-prefixes))
|
||||
(let [entry (doto (DirCacheEntry. path)
|
||||
(.setFileMode (.getFileMode tree-walk 0))
|
||||
(.setObjectId (.getObjectId tree-walk 0)))]
|
||||
@@ -396,7 +419,32 @@
|
||||
(version [this]
|
||||
(:version this)))
|
||||
|
||||
(defn- snapshot
|
||||
(def ^:private jgit (atom {}))
|
||||
|
||||
(defn- stale-cache-error?
|
||||
"Returns true if the exception indicates a stale git cache (e.g., after a force-push on the remote)."
|
||||
[^Exception e]
|
||||
(some-> (ex-message e) (str/includes? "Missing commit")))
|
||||
|
||||
(defn- clear-cached-repo!
|
||||
"Clears a cached git repository from memory and disk."
|
||||
[^File repo-path]
|
||||
(log/info "Clearing stale git cache" {:repo-path (str repo-path)})
|
||||
(swap! jgit dissoc (.getPath repo-path))
|
||||
(FileUtils/deleteDirectory repo-path))
|
||||
|
||||
(defn- get-jgit [^File path {:keys [remote-url token] :as args}]
|
||||
(if-let [obj (get @jgit (.getPath path))]
|
||||
obj
|
||||
(get (swap! jgit assoc (.getPath path) (u/prog1 (open-jgit path {:remote-url remote-url
|
||||
:token token})
|
||||
(when-not (has-data? (assoc args :git <>))
|
||||
(FileUtils/deleteDirectory path)
|
||||
(throw (ex-info "Cannot connect to uninitialized repository" {:url remote-url})))))
|
||||
(.getPath path))))
|
||||
|
||||
(defn- snapshot*
|
||||
"Internal snapshot implementation. Returns a GitSnapshot or throws."
|
||||
[source]
|
||||
(fetch! source)
|
||||
(let [version (commit-sha source (:branch source))]
|
||||
@@ -404,6 +452,21 @@
|
||||
(->GitSnapshot (:git source) (:remote-url source) (:branch source) version (:token source))
|
||||
(throw (ex-info (str "Invalid branch: " (:branch source)) {})))))
|
||||
|
||||
(defn- snapshot
|
||||
"Creates a snapshot, recovering from stale cache errors by re-cloning."
|
||||
[{:keys [remote-url token] :as source}]
|
||||
(try
|
||||
(snapshot* source)
|
||||
(catch Exception e
|
||||
(if (stale-cache-error? e)
|
||||
(let [path (repo-path {:remote-url remote-url :token token})]
|
||||
(clear-cached-repo! path)
|
||||
(let [fresh-git (get-jgit path {:remote-url remote-url :token token})
|
||||
fresh-source (assoc source :git fresh-git)]
|
||||
(log/info "Retrying snapshot after clearing stale cache")
|
||||
(snapshot* fresh-source)))
|
||||
(throw e)))))
|
||||
|
||||
(defrecord GitSource [git remote-url branch token]
|
||||
source.p/Source
|
||||
(branches [source] (branches source))
|
||||
@@ -417,18 +480,6 @@
|
||||
(snapshot [this]
|
||||
(snapshot this)))
|
||||
|
||||
(def ^:private jgit (atom {}))
|
||||
|
||||
(defn- get-jgit [^File path {:keys [remote-url token] :as args}]
|
||||
(if-let [obj (get @jgit (.getPath path))]
|
||||
obj
|
||||
(get (swap! jgit assoc (.getPath path) (u/prog1 (open-jgit path {:remote-url remote-url
|
||||
:token token})
|
||||
(when-not (has-data? (assoc args :git <>))
|
||||
(FileUtils/deleteDirectory path)
|
||||
(throw (ex-info "Cannot connect to uninitialized repository" {:url remote-url})))))
|
||||
(.getPath path))))
|
||||
|
||||
(defn git-source
|
||||
"Creates a new GitSource instance for a git repository.
|
||||
|
||||
|
||||
809
enterprise/backend/src/metabase_enterprise/remote_sync/spec.clj
Normal file
809
enterprise/backend/src/metabase_enterprise/remote_sync/spec.clj
Normal file
@@ -0,0 +1,809 @@
|
||||
(ns metabase-enterprise.remote-sync.spec
|
||||
"Centralized specifications for remote-syncable models.
|
||||
|
||||
Each spec defines everything needed for a model to participate in remote sync:
|
||||
- Event handling (what events to listen to, how to determine status)
|
||||
- Eligibility checking (when should this model be tracked)
|
||||
- Dirty tracking (how to create/update RemoteSyncObject entries)
|
||||
- Identity resolution (for import/export matching)
|
||||
- Path construction (for serialization paths)
|
||||
|
||||
To add a new syncable model, add a spec entry to `remote-sync-specs` and
|
||||
optionally implement custom multimethods if the default behavior doesn't fit."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.walk :as walk]
|
||||
[metabase-enterprise.remote-sync.settings :as rs-settings]
|
||||
[metabase.collections.core :as collections]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.settings.core :as setting]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; ------------------------------------------------- Spec Definitions -------------------------------------------------
|
||||
|
||||
(def remote-sync-specs
|
||||
"Map of model keyword to its remote-sync specification.
|
||||
|
||||
Each spec contains:
|
||||
- :model-type - String name for RemoteSyncObject model_type column
|
||||
- :model-key - Toucan2 model keyword (e.g., :model/Card)
|
||||
- :identity - Identity strategy: :entity-id, :path, or :hybrid
|
||||
- :path-keys - For path-based: vector of path components [:database :schema :table :field]
|
||||
- :events - Event configuration map:
|
||||
:prefix - Event keyword prefix (e.g., :event/card)
|
||||
:types - Vector of event types to handle [:create :update :delete]
|
||||
- :eligibility - Eligibility configuration:
|
||||
:type - :collection, :published-table, :parent-table, or :setting
|
||||
:collection - For :collection type: :remote-synced, :transforms-namespace, or :any
|
||||
:setting - For :setting type: setting keyword to check
|
||||
- :archived-key - Key to check for archived status (nil if model has no archived concept)
|
||||
- :tracking - RemoteSyncObject field configuration:
|
||||
:select-fields - Fields to select for hydration
|
||||
:hydrate-query - Optional custom query for joins (overrides select-fields)
|
||||
:field-mappings - Map of RemoteSyncObject column -> source field or [field transform-fn]
|
||||
- :removal - Removal/cleanup configuration:
|
||||
:statuses - Set of statuses to check for removal (e.g., #{\"removed\" \"delete\"})
|
||||
:scope-key - Optional key for scoping deletions (e.g., :collection_id, :id).
|
||||
If nil, deletions are global (by entity_id only).
|
||||
- :export-path - Export path construction:
|
||||
:type - Path type: :collection-entity, :table-path, :field-path, :segment-path,
|
||||
:transform-path, :transform-tag-path
|
||||
- :side-effects - Optional side effects:
|
||||
:on-first-sync - Function to call when model first becomes synced
|
||||
- :enabled? - true or setting keyword (e.g., :remote-sync-transforms)"
|
||||
{:model/Card
|
||||
{:model-type "Card"
|
||||
:model-key :model/Card
|
||||
:identity :entity-id
|
||||
:events {:prefix :event/card
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :collection
|
||||
:collection :remote-synced}
|
||||
:archived-key :archived
|
||||
:tracking {:select-fields [:name :collection_id :display]
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :collection_id
|
||||
:model_display [:display #(some-> % name)]}}
|
||||
:removal {:statuses #{"removed"}
|
||||
:scope-key :collection_id}
|
||||
:export-path {:type :collection-entity}
|
||||
:enabled? true}
|
||||
|
||||
:model/Dashboard
|
||||
{:model-type "Dashboard"
|
||||
:model-key :model/Dashboard
|
||||
:identity :entity-id
|
||||
:events {:prefix :event/dashboard
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :collection
|
||||
:collection :remote-synced}
|
||||
:archived-key :archived
|
||||
:tracking {:select-fields [:name :collection_id]
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :collection_id}}
|
||||
:removal {:statuses #{"removed"}
|
||||
:scope-key :collection_id}
|
||||
:export-path {:type :collection-entity}
|
||||
:enabled? true}
|
||||
|
||||
:model/Document
|
||||
{:model-type "Document"
|
||||
:model-key :model/Document
|
||||
:identity :entity-id
|
||||
:events {:prefix :event/document
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :collection
|
||||
:collection :remote-synced}
|
||||
:archived-key :archived
|
||||
:tracking {:select-fields [:name :collection_id]
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :collection_id}}
|
||||
:removal {:statuses #{"removed"}
|
||||
:scope-key :collection_id}
|
||||
:export-path {:type :collection-entity}
|
||||
:enabled? true}
|
||||
|
||||
:model/NativeQuerySnippet
|
||||
{:model-type "NativeQuerySnippet"
|
||||
:model-key :model/NativeQuerySnippet
|
||||
:identity :entity-id
|
||||
:events {:prefix :event/snippet
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :collection
|
||||
:collection :remote-synced}
|
||||
:archived-key :archived
|
||||
:tracking {:select-fields [:name :id] ; snippets don't have collection_id directly
|
||||
:field-mappings {:model_name :name}}
|
||||
:removal {:statuses #{"removed"}
|
||||
:scope-key :collection_id}
|
||||
:export-path {:type :collection-entity}
|
||||
:enabled? true}
|
||||
|
||||
:model/Timeline
|
||||
{:model-type "Timeline"
|
||||
:model-key :model/Timeline
|
||||
:identity :entity-id
|
||||
:events {:prefix :event/timeline
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :collection
|
||||
:collection :remote-synced}
|
||||
:archived-key :archived
|
||||
:tracking {:select-fields [:name :collection_id]
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :collection_id}}
|
||||
:removal {:statuses #{"removed"}
|
||||
:scope-key :collection_id}
|
||||
:export-path {:type :collection-entity}
|
||||
:enabled? true}
|
||||
|
||||
:model/Collection
|
||||
{:model-type "Collection"
|
||||
:model-key :model/Collection
|
||||
:identity :entity-id
|
||||
:events {:prefix :event/collection
|
||||
:types [:create :update]} ; no delete event for collections
|
||||
:eligibility {:type :collection
|
||||
:collection :any} ; remote-synced OR transforms-namespace
|
||||
:archived-key :archived
|
||||
:tracking {:select-fields [:name :id]
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :id}} ; collection_id is self
|
||||
:removal {:statuses #{"removed" "delete"} ; delete is set when collection is archived
|
||||
:scope-key :id} ; collections are scoped by their own id
|
||||
:export-path {:type :collection-entity}
|
||||
:export-scope :root-collections ; query for root-level remote-synced + transforms-namespace collections
|
||||
:side-effects {:on-first-sync :track-published-tables}
|
||||
:enabled? true}
|
||||
|
||||
:model/Table
|
||||
{:model-type "Table"
|
||||
:model-key :model/Table
|
||||
:identity :path
|
||||
:path-keys [:database :schema :table]
|
||||
:events {:prefix :event/table
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :published-table}
|
||||
:archived-key :archived_at ; tables use archived_at, not archived
|
||||
:tracking {:select-fields [:name :collection_id]
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :collection_id
|
||||
:model_table_id :id
|
||||
:model_table_name :name}} ; self-reference
|
||||
:removal {:statuses #{"removed"}}
|
||||
:export-path {:type :table-path}
|
||||
:enabled? true}
|
||||
|
||||
:model/Field
|
||||
{:model-type "Field"
|
||||
:model-key :model/Field
|
||||
:identity :path
|
||||
:path-keys [:database :schema :table :field]
|
||||
:parent-model :model/Table
|
||||
:events {:prefix :event/field
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :parent-table}
|
||||
:archived-key nil ; fields don't have archived
|
||||
:tracking {:hydrate-query {:select [:f.name :f.table_id
|
||||
[:t.collection_id :collection_id]
|
||||
[:t.name :table_name]]
|
||||
:from [[:metabase_field :f]]
|
||||
:join [[:metabase_table :t] [:= :f.table_id :t.id]]
|
||||
:where [:= :f.id :?id]}
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :collection_id
|
||||
:model_table_id :table_id
|
||||
:model_table_name :table_name}}
|
||||
:removal {:statuses #{"removed"}}
|
||||
:export-path {:type :field-path}
|
||||
:enabled? true}
|
||||
|
||||
:model/Segment
|
||||
{:model-type "Segment"
|
||||
:model-key :model/Segment
|
||||
:identity :hybrid ; entity_id but needs table context for path
|
||||
:path-keys [:database :schema :table]
|
||||
:parent-model :model/Table
|
||||
:events {:prefix :event/segment
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :parent-table}
|
||||
:archived-key :archived
|
||||
:tracking {:hydrate-query {:select [:s.name :s.table_id
|
||||
[:t.collection_id :collection_id]
|
||||
[:t.name :table_name]]
|
||||
:from [[:segment :s]]
|
||||
:join [[:metabase_table :t] [:= :s.table_id :t.id]]
|
||||
:where [:= :s.id :?id]}
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :collection_id
|
||||
:model_table_id :table_id
|
||||
:model_table_name :table_name}}
|
||||
:removal {:statuses #{"removed" "delete"}} ; Segment has both
|
||||
:export-path {:type :segment-path}
|
||||
:enabled? true}
|
||||
|
||||
:model/Measure
|
||||
{:model-type "Measure"
|
||||
:model-key :model/Measure
|
||||
:identity :hybrid ; entity_id but needs table context for path
|
||||
:path-keys [:database :schema :table]
|
||||
:parent-model :model/Table
|
||||
:events {:prefix :event/measure
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :parent-table}
|
||||
:archived-key :archived
|
||||
;; Note: hydrate-query uses :s alias because query-entities-for-sync :hybrid
|
||||
;; hardcodes :s.id and :s.entity_id references
|
||||
:tracking {:hydrate-query {:select [:s.name :s.table_id
|
||||
[:t.collection_id :collection_id]
|
||||
[:t.name :table_name]]
|
||||
:from [[:measure :s]]
|
||||
:join [[:metabase_table :t] [:= :s.table_id :t.id]]
|
||||
:where [:= :s.id :?id]}
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :collection_id
|
||||
:model_table_id :table_id
|
||||
:model_table_name :table_name}}
|
||||
:removal {:statuses #{"removed" "delete"}}
|
||||
:export-path {:type :measure-path}
|
||||
:enabled? true}
|
||||
|
||||
:model/Transform
|
||||
{:model-type "Transform"
|
||||
:model-key :model/Transform
|
||||
:identity :entity-id
|
||||
:events {:prefix :event/transform
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :setting
|
||||
:setting :remote-sync-transforms}
|
||||
:archived-key :archived
|
||||
:tracking {:select-fields [:name :collection_id]
|
||||
:field-mappings {:model_name :name
|
||||
:model_collection_id :collection_id}}
|
||||
:removal {:statuses #{"removed" "delete"}} ; no scope-key = global deletion
|
||||
:export-path {:type :transform-path}
|
||||
:export-scope :root-only ; query for root transforms (collection_id = nil)
|
||||
:enabled? :remote-sync-transforms}
|
||||
|
||||
:model/TransformTag
|
||||
{:model-type "TransformTag"
|
||||
:model-key :model/TransformTag
|
||||
:identity :entity-id
|
||||
:events {:prefix :event/transform-tag
|
||||
:types [:create :update :delete]}
|
||||
:eligibility {:type :setting
|
||||
:setting :remote-sync-transforms}
|
||||
:archived-key nil ; no archived field
|
||||
:tracking {:select-fields [:name]
|
||||
:field-mappings {:model_name :name}}
|
||||
:removal {:statuses #{"removed" "delete"}} ; no scope-key = global deletion
|
||||
:export-path {:type :transform-tag-path}
|
||||
:export-scope :all ; query for all instances
|
||||
:enabled? :remote-sync-transforms}})
|
||||
|
||||
;;; ------------------------------------------------- Helper Functions -------------------------------------------------
|
||||
|
||||
(defn spec-enabled?
|
||||
"Returns true if the spec is currently enabled based on its :enabled? value."
|
||||
[{:keys [enabled?]}]
|
||||
(cond
|
||||
(true? enabled?) true
|
||||
(keyword? enabled?) (boolean (setting/get-value-of-type :boolean enabled?))
|
||||
:else false))
|
||||
|
||||
(defn enabled-specs
|
||||
"Returns a map of model-key -> spec for all currently enabled specs."
|
||||
[]
|
||||
(into {}
|
||||
(filter (fn [[_ spec]] (spec-enabled? spec)))
|
||||
remote-sync-specs))
|
||||
|
||||
(defn spec-for-model-type
|
||||
"Returns the spec for a given model type string (e.g., \"Card\")."
|
||||
[model-type-str]
|
||||
(some (fn [[_ spec]]
|
||||
(when (= (:model-type spec) model-type-str)
|
||||
spec))
|
||||
remote-sync-specs))
|
||||
|
||||
(defn spec-for-model-key
|
||||
"Returns the spec for a given model key (e.g., :model/Card)."
|
||||
[model-key]
|
||||
(get remote-sync-specs model-key))
|
||||
|
||||
(defn specs-by-identity-type
|
||||
"Returns a map of model-key -> spec filtered by identity type."
|
||||
[identity-type]
|
||||
(into {}
|
||||
(filter (fn [[_ spec]] (= (:identity spec) identity-type)))
|
||||
(enabled-specs)))
|
||||
|
||||
(defn excluded-model-types
|
||||
"Returns a set of model type strings that should be excluded from dirty detection
|
||||
based on current settings. Models with a setting-based :enabled? that is currently
|
||||
false will be excluded."
|
||||
[]
|
||||
(->> remote-sync-specs
|
||||
(filter (fn [[_ spec]]
|
||||
(and (keyword? (:enabled? spec))
|
||||
(not (setting/get-value-of-type :boolean (:enabled? spec))))))
|
||||
(map (fn [[_ spec]] (:model-type spec)))
|
||||
set))
|
||||
|
||||
(defn all-model-types
|
||||
"Returns a set of all model type strings."
|
||||
[]
|
||||
(into #{} (map :model-type (vals remote-sync-specs))))
|
||||
|
||||
;;; ------------------------------------------------ Eligibility Checking ----------------------------------------------
|
||||
|
||||
(defn- transforms-namespace-collection?
|
||||
"Check if this is a transforms-namespace collection."
|
||||
[object]
|
||||
(= (keyword (:namespace object)) :transforms))
|
||||
|
||||
(defmulti check-eligibility
|
||||
"Determines if a model instance should be tracked for remote sync.
|
||||
Dispatches on the eligibility type defined in the spec."
|
||||
{:arglists '([spec object])}
|
||||
(fn [spec _object] (get-in spec [:eligibility :type])))
|
||||
|
||||
(defmethod check-eligibility :collection
|
||||
[{:keys [eligibility]} object]
|
||||
(let [collection-type (:collection eligibility)
|
||||
collection-id (:collection_id object)]
|
||||
(case collection-type
|
||||
:remote-synced
|
||||
(boolean (collections/remote-synced-collection? collection-id))
|
||||
|
||||
:transforms-namespace
|
||||
(and (rs-settings/remote-sync-transforms)
|
||||
(transforms-namespace-collection? object))
|
||||
|
||||
:any
|
||||
(or (collections/remote-synced-collection? (or collection-id object))
|
||||
(and (rs-settings/remote-sync-transforms)
|
||||
(transforms-namespace-collection? object)))
|
||||
|
||||
false)))
|
||||
|
||||
(defmethod check-eligibility :published-table
|
||||
[_ {:keys [is_published collection_id]}]
|
||||
(boolean
|
||||
(and is_published
|
||||
(collections/remote-synced-collection? collection_id))))
|
||||
|
||||
(defmethod check-eligibility :parent-table
|
||||
[{:keys [parent-model]} {:keys [table_id]}]
|
||||
(boolean
|
||||
(when table_id
|
||||
(when-let [table (t2/select-one parent-model :id table_id)]
|
||||
(check-eligibility (spec-for-model-key parent-model) table)))))
|
||||
|
||||
(defmethod check-eligibility :setting
|
||||
[{:keys [eligibility]} _object]
|
||||
(boolean (setting/get-value-of-type :boolean (:setting eligibility))))
|
||||
|
||||
(defmethod check-eligibility :default
|
||||
[_ _]
|
||||
false)
|
||||
|
||||
;;; ---------------------------------------------------- Hydration -----------------------------------------------------
|
||||
|
||||
(defn hydrate-model-details
|
||||
"Hydrates model details for RemoteSyncObject based on spec.
|
||||
Returns a map with the fields needed to populate the sync object."
|
||||
[{:keys [model-key tracking]} model-id]
|
||||
(if-let [query (:hydrate-query tracking)]
|
||||
;; Use custom query for joins (Field, Segment)
|
||||
(first (t2/query (update query :where
|
||||
(fn [where-clause]
|
||||
(walk/postwalk
|
||||
#(if (= % :?id) model-id %)
|
||||
where-clause)))))
|
||||
;; Simple select using select-fields
|
||||
(when-let [fields (:select-fields tracking)]
|
||||
(t2/select-one (into [model-key] fields) :id model-id))))
|
||||
|
||||
(defn build-sync-object-fields
|
||||
"Builds the fields map for RemoteSyncObject from hydrated model details.
|
||||
Applies any transform functions defined in field-mappings."
|
||||
[{:keys [tracking]} model-details]
|
||||
(when model-details
|
||||
(into {}
|
||||
(for [[sync-field source-spec] (:field-mappings tracking)]
|
||||
(let [value (cond
|
||||
;; Simple keyword - just get the field
|
||||
(keyword? source-spec)
|
||||
(get model-details source-spec)
|
||||
|
||||
;; Vector with transform function [field transform-fn]
|
||||
(vector? source-spec)
|
||||
(let [[field transform-fn] source-spec]
|
||||
(transform-fn (get model-details field)))
|
||||
|
||||
:else
|
||||
source-spec)]
|
||||
[sync-field value])))))
|
||||
|
||||
;;; ------------------------------------------------ Identity Resolution -----------------------------------------------
|
||||
|
||||
(defmulti extract-identity
|
||||
"Extracts the identity for a model instance based on the spec's identity strategy.
|
||||
Used for matching during import/export."
|
||||
{:arglists '([spec instance])}
|
||||
(fn [spec _instance] (:identity spec)))
|
||||
|
||||
(defmethod extract-identity :entity-id
|
||||
[_ instance]
|
||||
{:entity-id (:entity_id instance)})
|
||||
|
||||
(defmethod extract-identity :path
|
||||
[{:keys [path-keys]} instance]
|
||||
;; Path-based identity uses the path-keys to build a unique identifier
|
||||
(select-keys instance path-keys))
|
||||
|
||||
(defmethod extract-identity :hybrid
|
||||
[{:keys [path-keys]} instance]
|
||||
;; Hybrid uses both entity_id and path context
|
||||
{:entity-id (:entity_id instance)
|
||||
:path (select-keys instance path-keys)})
|
||||
|
||||
;;; --------------------------------------------- Export Path Construction ---------------------------------------------
|
||||
|
||||
(defn- transform-entity-for-serdes
|
||||
"Transforms entity fields to serdes format for path generation.
|
||||
- Converts integer table_id to [db-name schema table-name] format"
|
||||
[entity]
|
||||
(cond-> entity
|
||||
;; Transform table_id for Segment (and other table-based entities)
|
||||
(and (:table_id entity) (integer? (:table_id entity)))
|
||||
(assoc :table_id (serdes/*export-table-fk* (:table_id entity)))))
|
||||
|
||||
(defn- entity->serdes-path
|
||||
"Builds the file path for an entity using serdes/storage-path.
|
||||
For Collections, returns the directory path (for recursive deletion).
|
||||
For other entities, returns the full file path.
|
||||
Returns the path as a string (without extension), or nil if path cannot be built."
|
||||
[model-type entity ctx]
|
||||
(try
|
||||
(when-let [serdes-meta (serdes/generate-path model-type entity)]
|
||||
(let [;; Transform entity fields to serdes format (e.g., table_id -> [db schema table])
|
||||
transformed-entity (transform-entity-for-serdes entity)
|
||||
entity-with-meta (assoc transformed-entity :serdes/meta serdes-meta)
|
||||
storage-path (serdes/storage-path entity-with-meta ctx)]
|
||||
;; For Collections, drop the last segment (filename) to get directory path
|
||||
(if (= model-type "Collection")
|
||||
(str/join "/" (butlast storage-path))
|
||||
(str/join "/" storage-path))))
|
||||
(catch Exception e
|
||||
(log/warnf "Failed to build storage path for %s %s: %s"
|
||||
model-type (:id entity) (ex-message e))
|
||||
nil)))
|
||||
|
||||
;;; -------------------------------------------- Event Helper Functions ------------------------------------------------
|
||||
|
||||
(defn determine-status
|
||||
"Determines the sync status based on event type and archived state."
|
||||
[{:keys [archived-key]} topic object]
|
||||
(let [archived? (and archived-key (get object archived-key))]
|
||||
(if archived?
|
||||
"delete"
|
||||
(let [topic-name (name topic)]
|
||||
(cond
|
||||
(str/ends-with? topic-name "-create") "create"
|
||||
(str/ends-with? topic-name "-update") "update"
|
||||
(str/ends-with? topic-name "-delete") "delete"
|
||||
:else "update")))))
|
||||
|
||||
(defn event-keywords
|
||||
"Returns a map of event keywords for a spec: {:parent :create :update :delete}."
|
||||
[{:keys [events]}]
|
||||
(let [{:keys [prefix types]} events
|
||||
ns-str (namespace prefix)
|
||||
base (name prefix)]
|
||||
(merge
|
||||
;; Parent keyword uses the event prefix name (e.g., :card -> card-change-event)
|
||||
{:parent (keyword "metabase-enterprise.remote-sync.events"
|
||||
(str base "-change-event"))}
|
||||
(into {}
|
||||
(for [event-type types]
|
||||
[event-type (keyword ns-str (str base "-" (name event-type)))])))))
|
||||
|
||||
;;; ---------------------------------------------- Spec Field Accessors ------------------------------------------------
|
||||
|
||||
(defn select-fields-for-sync
|
||||
"Returns the fields to select for a model during sync operations.
|
||||
Falls back to default if not specified in spec."
|
||||
[model-type-str]
|
||||
(if-let [spec (spec-for-model-type model-type-str)]
|
||||
(or (get-in spec [:tracking :select-fields])
|
||||
[:id :name :collection_id])
|
||||
[:id :name :collection_id]))
|
||||
|
||||
;;; -------------------------------------------- Removal Path Building --------------------------------------------------
|
||||
|
||||
(defn- query-removed-ids
|
||||
"Queries RemoteSyncObject for model IDs marked for removal."
|
||||
[model-type statuses]
|
||||
(t2/select-fn-set :model_id :model/RemoteSyncObject
|
||||
:model_type model-type
|
||||
:status [:in (vec statuses)]))
|
||||
|
||||
(defn- query-removed-entities
|
||||
"Queries entities marked for removal with all fields needed for serdes path generation.
|
||||
serdes/generate-path needs: entity_id, name (for label)
|
||||
serdes/storage-path needs: collection_id (for collection context), table_id (for segment paths)"
|
||||
[{:keys [model-type model-key removal]}]
|
||||
(let [{:keys [statuses]} removal
|
||||
removed-ids (query-removed-ids model-type statuses)]
|
||||
(when (seq removed-ids)
|
||||
;; Select all columns since different models need different fields for serdes paths
|
||||
(t2/select model-key :id [:in removed-ids]))))
|
||||
|
||||
(defn build-all-removal-paths
|
||||
"Builds full file paths for all entities marked for removal in RemoteSyncObject.
|
||||
Uses serdes/storage-path to generate paths that exactly match the file structure.
|
||||
|
||||
Does bulk queries per model type for efficiency:
|
||||
1. Query RemoteSyncObject for model_ids with removal statuses
|
||||
2. Query actual entities by those IDs
|
||||
3. Use serdes/storage-path to build the exact file path
|
||||
|
||||
Returns paths without file extensions - the git source adds .yaml as needed."
|
||||
[]
|
||||
(let [ctx (serdes/storage-base-context)]
|
||||
(into []
|
||||
(for [[_model-key spec] (enabled-specs)
|
||||
:when (spec-enabled? spec)
|
||||
:let [{:keys [statuses]} (:removal spec)
|
||||
model-type (:model-type spec)]
|
||||
:when (seq statuses)
|
||||
entity (query-removed-entities spec)
|
||||
:let [path (entity->serdes-path model-type entity ctx)]
|
||||
:when path]
|
||||
path))))
|
||||
|
||||
;;; ----------------------------------------- Sync Object Query Functions --------------------------------------------
|
||||
|
||||
(defmulti query-entities-for-sync
|
||||
"Queries entities for sync based on identity type.
|
||||
Returns a sequence of maps ready for RemoteSyncObject insertion.
|
||||
|
||||
Parameters:
|
||||
- spec: The model spec
|
||||
- data: For :entity-id/:hybrid - a collection of entity_ids
|
||||
For :path - a collection of path maps {:db_name :schema :table_name [:field_name]}
|
||||
- timestamp: The sync timestamp"
|
||||
{:arglists '([spec data timestamp])}
|
||||
(fn [spec _data _timestamp] (:identity spec)))
|
||||
|
||||
(defmethod query-entities-for-sync :entity-id
|
||||
[{:keys [model-type model-key tracking]} entity-ids timestamp]
|
||||
(when (seq entity-ids)
|
||||
(let [;; Get select fields from spec, with :id always included
|
||||
select-fields (into [:id] (or (:select-fields tracking) [:name :collection_id]))
|
||||
entities (t2/select (into [model-key] select-fields) :entity_id [:in entity-ids])]
|
||||
(map (fn [entity]
|
||||
(let [;; Apply field mappings
|
||||
field-mappings (:field-mappings tracking)
|
||||
mapped-fields (into {}
|
||||
(for [[sync-field source-spec] field-mappings]
|
||||
(let [value (cond
|
||||
(keyword? source-spec)
|
||||
(get entity source-spec)
|
||||
(vector? source-spec)
|
||||
(let [[field transform-fn] source-spec]
|
||||
(transform-fn (get entity field)))
|
||||
:else source-spec)]
|
||||
[sync-field value])))]
|
||||
(merge {:model_type model-type
|
||||
:model_id (:id entity)
|
||||
:status "synced"
|
||||
:status_changed_at timestamp}
|
||||
mapped-fields)))
|
||||
entities))))
|
||||
|
||||
(defmethod query-entities-for-sync :hybrid
|
||||
;; Hybrid models like Segment need a join query for table info
|
||||
[{:keys [model-type tracking]} entity-ids timestamp]
|
||||
(when (seq entity-ids)
|
||||
(if-let [query-template (:hydrate-query tracking)]
|
||||
;; Use custom hydrate query adapted for batch lookup
|
||||
;; Keep the existing from/join structure, just modify select and where
|
||||
;; The query template uses alias :s for the main model (segment)
|
||||
(let [base-query (-> query-template
|
||||
;; Add :id to select for model_id field
|
||||
(update :select (fn [cols] (vec (concat [:s.id] cols))))
|
||||
;; Replace where clause to use batch entity_id lookup
|
||||
(assoc :where [:in :s.entity_id entity-ids]))]
|
||||
(->> (t2/query base-query)
|
||||
(map (fn [entity]
|
||||
(let [field-mappings (:field-mappings tracking)
|
||||
mapped-fields (into {}
|
||||
(for [[sync-field source-spec] field-mappings]
|
||||
[sync-field (get entity source-spec)]))]
|
||||
(merge {:model_type model-type
|
||||
:model_id (:id entity)
|
||||
:status "synced"
|
||||
:status_changed_at timestamp}
|
||||
mapped-fields))))))
|
||||
;; Fallback - shouldn't happen for hybrid models with proper spec
|
||||
nil)))
|
||||
|
||||
(defn- build-path-where-clause
|
||||
"Builds an :or where clause for path-based lookups."
|
||||
[paths has-field?]
|
||||
(into [:or]
|
||||
(for [path paths]
|
||||
(let [{:keys [db_name schema table_name field_name]} path]
|
||||
(cond-> [:and
|
||||
[:= :db.name db_name]
|
||||
(if schema [:= :t.schema schema] [:is :t.schema nil])
|
||||
[:= :t.name table_name]]
|
||||
(and has-field? field_name)
|
||||
(conj [:= :f.name field_name]))))))
|
||||
|
||||
(defmethod query-entities-for-sync :path
|
||||
[{:keys [model-type model-key]} paths timestamp]
|
||||
(when (seq paths)
|
||||
(case model-key
|
||||
:model/Table
|
||||
(->> (t2/query {:select [:t.id :t.name :t.collection_id]
|
||||
:from [[:metabase_table :t]]
|
||||
:join [[:metabase_database :db] [:= :db.id :t.db_id]]
|
||||
:where (build-path-where-clause paths false)})
|
||||
(map (fn [{:keys [id name collection_id]}]
|
||||
{:model_type model-type
|
||||
:model_id id
|
||||
:model_name name
|
||||
:model_collection_id collection_id
|
||||
:model_display nil
|
||||
:model_table_id id
|
||||
:model_table_name name
|
||||
:status "synced"
|
||||
:status_changed_at timestamp})))
|
||||
|
||||
:model/Field
|
||||
(->> (t2/query {:select [:f.id :f.name :f.table_id [:t.collection_id :collection_id] [:t.name :table_name]]
|
||||
:from [[:metabase_field :f]]
|
||||
:join [[:metabase_table :t] [:= :t.id :f.table_id]
|
||||
[:metabase_database :db] [:= :db.id :t.db_id]]
|
||||
:where (build-path-where-clause paths true)})
|
||||
(map (fn [{:keys [id name table_id table_name collection_id]}]
|
||||
{:model_type model-type
|
||||
:model_id id
|
||||
:model_name name
|
||||
:model_collection_id collection_id
|
||||
:model_display nil
|
||||
:model_table_id table_id
|
||||
:model_table_name table_name
|
||||
:status "synced"
|
||||
:status_changed_at timestamp})))
|
||||
|
||||
;; Default for unknown path models
|
||||
nil)))
|
||||
|
||||
(defmethod query-entities-for-sync :default
|
||||
[_ _ _]
|
||||
nil)
|
||||
|
||||
(defn sync-all-entities!
|
||||
"Syncs all entities based on specs. Takes:
|
||||
- timestamp: The sync timestamp
|
||||
- imported-entities-by-model: Map of model-type string -> set of entity_ids
|
||||
- table-paths: Collection of table path maps
|
||||
- field-paths: Collection of field path maps
|
||||
|
||||
Returns a sequence of all sync objects to insert."
|
||||
[timestamp imported-entities-by-model table-paths field-paths]
|
||||
(into []
|
||||
(for [[model-key spec] (enabled-specs)
|
||||
:let [identity-type (:identity spec)
|
||||
model-type (:model-type spec)
|
||||
data (case identity-type
|
||||
:entity-id (get imported-entities-by-model model-type)
|
||||
:path (case model-key
|
||||
:model/Table table-paths
|
||||
:model/Field field-paths
|
||||
nil)
|
||||
:hybrid (get imported-entities-by-model model-type))]
|
||||
:when (seq data)
|
||||
entity (query-entities-for-sync spec data timestamp)]
|
||||
entity)))
|
||||
|
||||
;;; -------------------------------------------- Export Extraction ------------------------------------------------
|
||||
|
||||
(defmulti query-export-roots
|
||||
"Queries for root-level export targets based on eligibility configuration.
|
||||
Returns a sequence of [model-type id] tuples for initial targets, or nil if
|
||||
this model type's targets are derived from other models (e.g., via serdes/descendants)."
|
||||
{:arglists '([spec])}
|
||||
(fn [spec] (get-in spec [:eligibility :type])))
|
||||
|
||||
(defmethod query-export-roots :collection
|
||||
[{:keys [export-scope]}]
|
||||
(case (or export-scope :derived)
|
||||
:root-collections
|
||||
;; Collection model: query for root-level remote-synced + transforms-namespace collections
|
||||
;; Excludes archived collections - their files are handled by the removal logic
|
||||
(concat
|
||||
(t2/select-fn-set (juxt (constantly "Collection") :id)
|
||||
:model/Collection
|
||||
{:where [:and
|
||||
[:= :is_remote_synced true]
|
||||
[:= :location "/"]
|
||||
[:not :archived]]})
|
||||
(when (rs-settings/remote-sync-transforms)
|
||||
(t2/select-fn-set (juxt (constantly "Collection") :id)
|
||||
:model/Collection
|
||||
{:where [:and
|
||||
[:= :namespace (name collections/transforms-ns)]
|
||||
[:= :location "/"]
|
||||
[:not :archived]]})))
|
||||
:derived
|
||||
;; Other collection-based models: no root query, derived from collection expansion
|
||||
nil))
|
||||
|
||||
(defmethod query-export-roots :setting
|
||||
[{:keys [export-scope model-key model-type] :as spec}]
|
||||
(when (spec-enabled? spec)
|
||||
(case export-scope
|
||||
:root-only
|
||||
;; Transform: root transforms (collection_id = nil)
|
||||
(t2/select-fn-set (juxt (constantly model-type) :id) model-key :collection_id nil)
|
||||
:all
|
||||
;; TransformTag: all instances
|
||||
(t2/select-fn-set (juxt (constantly model-type) :id) model-key)
|
||||
;; Default: no root query
|
||||
nil)))
|
||||
|
||||
(defmethod query-export-roots :published-table [_] nil) ; Derived via serdes/descendants
|
||||
(defmethod query-export-roots :parent-table [_] nil) ; Derived via serdes/descendants
|
||||
|
||||
(defmethod query-export-roots :default [_] nil)
|
||||
|
||||
(defn- resolve-targets
|
||||
"Expands collection IDs to include all descendant collections.
|
||||
Takes a set of collection IDs and returns a set including the original IDs
|
||||
plus all IDs of nested collections."
|
||||
[targets opts]
|
||||
(when (seq targets)
|
||||
(merge-with into
|
||||
(u/traverse targets #(serdes/descendants (first %) (second %) opts))
|
||||
(u/traverse targets #(serdes/required (first %) (second %))))))
|
||||
|
||||
(defn extract-entities-for-export
|
||||
"Extracts all entities for remote-sync export based on enabled specs.
|
||||
|
||||
Returns a lazy sequence of serialized entities ready for storage.
|
||||
|
||||
Iterates over enabled specs and uses `query-export-roots` to find root targets
|
||||
for each model type. Models with `:export-scope :derived` or no export-scope
|
||||
are expanded from other targets via serdes/descendants.
|
||||
|
||||
Only extracts models that:
|
||||
1. Have a spec in remote-sync-specs
|
||||
2. Are currently enabled (based on :enabled? field)
|
||||
3. Are in one of the provided collections (or descendants)"
|
||||
[]
|
||||
(let [specs (enabled-specs)
|
||||
;; Collect all root targets by iterating over enabled specs
|
||||
root-targets (into []
|
||||
(comp (map val)
|
||||
(mapcat query-export-roots)
|
||||
(filter identity))
|
||||
specs)]
|
||||
(when-let [targets (resolve-targets
|
||||
root-targets
|
||||
{:include-field-values false
|
||||
:include-database-secrets false
|
||||
:continue-on-error false
|
||||
:skip-archived true})]
|
||||
(eduction (map (fn [[model ids]]
|
||||
(serdes/extract-all model {:where [:in :id ids]})))
|
||||
cat
|
||||
(u/group-by first second (keys targets))))))
|
||||
@@ -27,7 +27,8 @@
|
||||
"Document"
|
||||
"Glossary"
|
||||
"NativeQuerySnippet"
|
||||
"Timeline"])
|
||||
"Timeline"
|
||||
"Transform"])
|
||||
|
||||
(def exported-models
|
||||
"The list of all models exported by serialization by default. Used for production code and by tests."
|
||||
@@ -36,7 +37,6 @@
|
||||
["FieldValues"
|
||||
"Metabot"
|
||||
"Setting"
|
||||
"Transform"
|
||||
"TransformJob"
|
||||
"TransformTag"]))
|
||||
|
||||
|
||||
@@ -10,21 +10,33 @@
|
||||
preferred-method (case preferred-method
|
||||
"jwt" :jwt
|
||||
"saml" :saml
|
||||
"slack-connect" :slack-connect
|
||||
(throw (ex-info "Invalid auth method"
|
||||
{:preferred-method preferred-method
|
||||
:available [:jwt :saml]})))
|
||||
:available [:jwt :saml :slack-connect]})))
|
||||
(contains? (:params req) :jwt) :jwt
|
||||
:else :saml)))
|
||||
|
||||
(defn- sso-backend
|
||||
"Function that powers the defmulti in figuring out which SSO backend to use. It might be that we need to have more
|
||||
complex logic around this, but now it's just a simple priority. If SAML is configured use that otherwise JWT"
|
||||
complex logic around this, but now it's just a simple priority. If multiple SSO methods are enabled, uses
|
||||
preferred_method parameter if provided."
|
||||
[req]
|
||||
(cond
|
||||
(and (sso-settings/saml-enabled) (sso-settings/jwt-enabled)) (select-sso-backend req)
|
||||
(sso-settings/saml-enabled) :saml
|
||||
(sso-settings/jwt-enabled) :jwt
|
||||
:else nil))
|
||||
(let [enabled-count (count (filter identity
|
||||
[(sso-settings/saml-enabled)
|
||||
(sso-settings/jwt-enabled)
|
||||
(sso-settings/slack-connect-enabled)]))]
|
||||
(cond
|
||||
;; Multiple SSO methods enabled - use preferred_method or selection logic
|
||||
(> enabled-count 1) (select-sso-backend req)
|
||||
|
||||
;; Single SSO method enabled
|
||||
(sso-settings/saml-enabled) :saml
|
||||
(sso-settings/jwt-enabled) :jwt
|
||||
(sso-settings/slack-connect-enabled) :slack-connect
|
||||
|
||||
;; No SSO method enabled
|
||||
:else nil)))
|
||||
|
||||
(defmulti sso-get
|
||||
"Multi-method for supporting the first part of an SSO signin request. An implementation of this method will usually
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[metabase-enterprise.sso.api.interface :as sso.i]
|
||||
[metabase-enterprise.sso.integrations.jwt :as jwt]
|
||||
[metabase-enterprise.sso.integrations.saml]
|
||||
[metabase-enterprise.sso.integrations.slack-connect]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.request.core :as request]
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
[metabase-enterprise.sso.integrations.google] ; has a `define-multi-setting` impl
|
||||
[metabase-enterprise.sso.providers.jwt]
|
||||
[metabase-enterprise.sso.providers.saml]
|
||||
[metabase-enterprise.sso.providers.slack-connect]
|
||||
[metabase-enterprise.sso.settings]))
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
(ns metabase-enterprise.sso.integrations.slack-connect
|
||||
"Implementation of the Slack Connect backend for SSO.
|
||||
|
||||
Slack Connect uses OIDC (OpenID Connect) for authentication, which only uses GET requests.
|
||||
Both the initial authorization request and the callback are handled via GET.
|
||||
|
||||
Flow:
|
||||
1. User accesses GET /auth/sso?preferred_method=slack-connect
|
||||
2. Metabase redirects to Slack authorization endpoint
|
||||
3. User authenticates with Slack
|
||||
4. Slack redirects back to GET /auth/sso?code=...&state=...
|
||||
5. Metabase exchanges code for tokens and creates session"
|
||||
(:require
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.sso.api.interface :as sso.i]
|
||||
[metabase-enterprise.sso.integrations.sso-utils :as sso-utils]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.auth-identity.core :as auth-identity]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.sso.core :as sso]
|
||||
[metabase.system.core :as system]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.log :as log]
|
||||
[ring.util.response :as response]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- slack-redirect-uri
|
||||
"Generate the redirect URI for Slack OIDC callback."
|
||||
[]
|
||||
(str (system/site-url) "/auth/sso"))
|
||||
|
||||
(defmethod sso.i/sso-get :slack-connect
|
||||
[{{:keys [code state redirect]} :params
|
||||
:as request}]
|
||||
;; Check premium feature is available
|
||||
(premium-features/assert-has-feature :sso-slack (tru "Slack Connect authentication"))
|
||||
|
||||
(when-not (sso-settings/slack-connect-enabled)
|
||||
(throw (ex-info (tru "Slack Connect is not enabled")
|
||||
{:status-code 400})))
|
||||
|
||||
(cond
|
||||
;; Case 1: Initial request (no code) - start auth flow
|
||||
(not code)
|
||||
(let [_ (when (and (= "link-only" (sso-settings/slack-connect-authentication-mode))
|
||||
(not api/*current-user-id*))
|
||||
(throw (ex-info (tru "Account linking requires an authenticated session")
|
||||
{:status-code 401})))
|
||||
redirect-url (if redirect
|
||||
(sso-utils/check-sso-redirect redirect)
|
||||
"/")
|
||||
auth-result (auth-identity/authenticate :provider/slack-connect
|
||||
(assoc request
|
||||
:authenticated-user api/*current-user*
|
||||
:redirect-uri (slack-redirect-uri)
|
||||
:final-redirect redirect-url))]
|
||||
|
||||
(cond
|
||||
(= :redirect (:success? auth-result))
|
||||
;; Use encrypted cookie for state storage
|
||||
(sso/wrap-oidc-redirect auth-result
|
||||
request
|
||||
:slack-connect
|
||||
redirect-url
|
||||
{:browser-id (:browser-id request)})
|
||||
:else
|
||||
(throw (ex-info (or (:message auth-result) (tru "Failed to initiate Slack authentication"))
|
||||
{:status-code 500}))))
|
||||
|
||||
;; Case 2: Callback with code - complete authentication
|
||||
;; State validation happens inside login! via the OIDC provider
|
||||
code
|
||||
(let [login-result (auth-identity/login! :provider/slack-connect
|
||||
(assoc request
|
||||
:authenticated-user api/*current-user*
|
||||
:code code
|
||||
:state state
|
||||
:oidc-provider :slack-connect
|
||||
:redirect-uri (slack-redirect-uri)
|
||||
:device-info (request/device-info request)))]
|
||||
|
||||
(cond
|
||||
(:success? login-result)
|
||||
(let [final-redirect (or (:redirect-url login-result) "/")
|
||||
base-response (-> (response/redirect final-redirect)
|
||||
(sso/clear-oidc-state-cookie))]
|
||||
(log/infof "Slack authentication successful for user %s" (get-in login-result [:user :email]))
|
||||
;; In link-only mode, there's no session to set - just return the redirect
|
||||
(if-let [session (:session login-result)]
|
||||
(request/set-session-cookies request
|
||||
base-response
|
||||
session
|
||||
(t/zoned-date-time (t/zone-id "GMT")))
|
||||
base-response))
|
||||
|
||||
;; Login failed (includes state validation failures)
|
||||
:else
|
||||
(let [error-msg (or (:message login-result) (tru "Slack authentication failed"))]
|
||||
(log/errorf "Slack authentication failed: %s" error-msg)
|
||||
(throw (ex-info error-msg {:status-code 401})))))))
|
||||
|
||||
;; OIDC only uses GET requests, so POST should not be used.
|
||||
;; Return 405 Method Not Allowed.
|
||||
(defmethod sso.i/sso-post :slack-connect
|
||||
[_request]
|
||||
{:status 405
|
||||
:headers {"Allow" "GET"}
|
||||
:body (tru "POST not supported for OIDC authentication. Use GET instead.")})
|
||||
@@ -37,6 +37,10 @@
|
||||
[_]
|
||||
(maybe-throw-user-provisioning (sso-settings/jwt-user-provisioning-enabled?)))
|
||||
|
||||
(defmethod check-user-provisioning :slack-connect
|
||||
[_]
|
||||
(maybe-throw-user-provisioning (sso-settings/slack-connect-user-provisioning-enabled)))
|
||||
|
||||
(defn relative-uri?
|
||||
"Checks that given `uri` is not an absolute (so no scheme and no host)."
|
||||
[uri]
|
||||
|
||||
@@ -134,10 +134,14 @@
|
||||
;; Authentication succeeded - check account creation policy
|
||||
;; TODO(edpaget): 2025/11/11 this should return an error condition instead of throwing
|
||||
:else
|
||||
(do (when-not (and (:user request) (get-in request [:user :is_active]))
|
||||
(sso-utils/check-user-provisioning :jwt))
|
||||
;; If the user was deactivated but user provisioning is allowed reactive the user
|
||||
(next-method provider (assoc-in request [:user-data :is_active] true)))))
|
||||
(let [provisioning-enabled? (sso-settings/jwt-user-provisioning-enabled?)]
|
||||
(when-not (and (:user request) (get-in request [:user :is_active]))
|
||||
(sso-utils/check-user-provisioning :jwt))
|
||||
;; If the user was deactivated but user provisioning is allowed reactive the user
|
||||
;; Pass provisioning status for tenant reactivation logic
|
||||
(next-method provider (-> request
|
||||
(assoc-in [:user-data :is_active] true)
|
||||
(assoc :user-provisioning-enabled? provisioning-enabled?))))))
|
||||
|
||||
(defn- group-names->ids
|
||||
"Translate a user's group names to a set of MB group IDs using the configured mappings"
|
||||
|
||||
@@ -163,10 +163,14 @@
|
||||
;; Authentication succeeded - check account creation policy
|
||||
;; TODO(edpaget): 2025/11/11 this should return an error condition instead of throwing
|
||||
:else
|
||||
(do (when-not (and (:user request) (get-in request [:user :is_active]))
|
||||
(sso-utils/check-user-provisioning :saml))
|
||||
;; If the user was deactivated but user provisioning is allowed reactive the user
|
||||
(next-method provider (assoc-in request [:user-data :is_active] true)))))
|
||||
(let [provisioning-enabled? (sso-settings/saml-user-provisioning-enabled?)]
|
||||
(when-not (and (:user request) (get-in request [:user :is_active]))
|
||||
(sso-utils/check-user-provisioning :saml))
|
||||
;; If the user was deactivated but user provisioning is allowed reactive the user
|
||||
;; Pass provisioning status for tenant reactivation logic
|
||||
(next-method provider (-> request
|
||||
(assoc-in [:user-data :is_active] true)
|
||||
(assoc :user-provisioning-enabled? provisioning-enabled?))))))
|
||||
|
||||
(defn- group-names->ids
|
||||
"Translate a user's group names to a set of MB group IDs using the configured mappings"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
(ns metabase-enterprise.sso.providers.slack-connect
|
||||
"Slack Connect OIDC authentication provider. Derives from the base OIDC provider
|
||||
and adds Slack-specific configuration and claim extraction."
|
||||
(:require
|
||||
[metabase-enterprise.sso.integrations.sso-utils :as sso-utils]
|
||||
[metabase-enterprise.sso.settings :as sso-settings]
|
||||
[metabase.auth-identity.core :as auth-identity]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; -------------------------------------------------- Provider Registration --------------------------------------------------
|
||||
|
||||
(derive :provider/slack-connect :provider/oidc)
|
||||
|
||||
;;; -------------------------------------------------- Constants --------------------------------------------------
|
||||
|
||||
(def provider-name
|
||||
"Provider name for Slack Connect authentication."
|
||||
"slack-connect")
|
||||
|
||||
(def slack-issuer-uri
|
||||
"Slack's OIDC issuer URI."
|
||||
"https://slack.com")
|
||||
|
||||
(def slack-team-image-claim
|
||||
"Claim key for Slack team image in ID token."
|
||||
"https://slack.com/team_image_230")
|
||||
|
||||
;;; -------------------------------------------------- Slack-Specific Utilities --------------------------------------------------
|
||||
|
||||
(defn- extract-slack-claims
|
||||
"Extract Slack-specific claims from ID token.
|
||||
Returns map with Slack team and user information for storage in login_attributes.
|
||||
Keys are strings to match the login_attributes schema [:map-of :string :string].
|
||||
|
||||
NOTE: team_name is NOT included in Slack ID tokens per research.
|
||||
Use team_id only or make additional API call if team name is required."
|
||||
[id-token-claims]
|
||||
(let [team-id-claim (sso-settings/slack-connect-attribute-team-id)]
|
||||
(cond-> {}
|
||||
(get id-token-claims team-id-claim)
|
||||
(assoc "slack-team-id" (get id-token-claims team-id-claim))
|
||||
|
||||
(:sub id-token-claims)
|
||||
(assoc "slack-user-id" (:sub id-token-claims))
|
||||
|
||||
(get id-token-claims slack-team-image-claim)
|
||||
(assoc "slack-team-image" (get id-token-claims slack-team-image-claim))
|
||||
|
||||
(:email_verified id-token-claims)
|
||||
(assoc "slack-email-verified" (str (:email_verified id-token-claims)))
|
||||
|
||||
(:locale id-token-claims)
|
||||
(assoc "slack-locale" (:locale id-token-claims)))))
|
||||
|
||||
(defn- build-slack-oidc-config
|
||||
"Build OIDC configuration map for parent provider.
|
||||
Uses Slack settings and hardcoded Slack-specific values."
|
||||
[request]
|
||||
(when (and (sso-settings/slack-connect-client-id)
|
||||
(sso-settings/slack-connect-client-secret))
|
||||
{:client-id (sso-settings/slack-connect-client-id)
|
||||
:client-secret (sso-settings/slack-connect-client-secret)
|
||||
:issuer-uri slack-issuer-uri
|
||||
:scopes ["openid" "profile" "email"]
|
||||
:redirect-uri (get request :redirect-uri)}))
|
||||
|
||||
;;; -------------------------------------------------- Authentication Implementation --------------------------------------------------
|
||||
|
||||
(methodical/defmethod auth-identity/authenticate :provider/slack-connect
|
||||
[_provider request]
|
||||
(cond
|
||||
(not (premium-features/enable-sso-slack?))
|
||||
{:success? false
|
||||
:error :feature-not-available
|
||||
:message (tru "Slack Connect authentication is not available on your plan")}
|
||||
|
||||
(not (sso-settings/slack-connect-enabled))
|
||||
{:success? false
|
||||
:error :slack-connect-not-enabled
|
||||
:message (tru "Slack Connect authentication is not enabled")}
|
||||
|
||||
(not (sso-settings/slack-connect-configured))
|
||||
{:success? false
|
||||
:error :slack-connect-not-configured
|
||||
:message (tru "Slack Connect is not configured")}
|
||||
|
||||
(and (= sso-settings/slack-connect-auth-mode-link-only (sso-settings/slack-connect-authentication-mode))
|
||||
(not (:code request))
|
||||
(not (some-> (:authenticated-user request) deref)))
|
||||
{:success? false
|
||||
:error :authentication-required
|
||||
:message (tru "Account linking requires an authenticated session")}
|
||||
|
||||
:else
|
||||
(let [oidc-config (build-slack-oidc-config request)]
|
||||
(if-not oidc-config
|
||||
{:success? false
|
||||
:error :configuration-error
|
||||
:message (tru "Failed to build Slack OIDC configuration")}
|
||||
|
||||
(let [auth-result (next-method _provider (assoc request :oidc-config oidc-config))]
|
||||
(if (and (:success? auth-result)
|
||||
(:user-data auth-result))
|
||||
(let [id-token-claims (or (get-in request [:id-token :claims])
|
||||
(get auth-result :claims))
|
||||
slack-attrs (when id-token-claims
|
||||
(extract-slack-claims id-token-claims))]
|
||||
(cond-> (assoc-in auth-result [:user-data :sso_source] :slack)
|
||||
slack-attrs
|
||||
(assoc-in [:user-data :login_attributes] slack-attrs)
|
||||
|
||||
slack-attrs
|
||||
(assoc :slack-data slack-attrs)))
|
||||
auth-result))))))
|
||||
|
||||
;;; -------------------------------------------------- Login Implementation --------------------------------------------------
|
||||
|
||||
(defn- create-auth-identity-for-link!
|
||||
"Create an AuthIdentity record linking an authenticated user to their Slack identity.
|
||||
Used in link-only mode where we don't create users or sessions."
|
||||
[user-id provider-id]
|
||||
(when (and user-id provider-id)
|
||||
(when-not (t2/exists? :model/AuthIdentity
|
||||
:user_id user-id
|
||||
:provider provider-name)
|
||||
(t2/insert! :model/AuthIdentity
|
||||
{:user_id user-id
|
||||
:provider provider-name
|
||||
:provider_id provider-id}))))
|
||||
|
||||
(methodical/defmethod auth-identity/login! :provider/slack-connect
|
||||
[provider {:keys [user authenticated-user user-data] :as request}]
|
||||
(condp = (sso-settings/slack-connect-authentication-mode)
|
||||
sso-settings/slack-connect-auth-mode-sso
|
||||
(do (when-not user
|
||||
(sso-utils/check-user-provisioning (keyword provider-name)))
|
||||
(next-method provider request))
|
||||
|
||||
sso-settings/slack-connect-auth-mode-link-only
|
||||
;; In link-only mode, create AuthIdentity for the authenticated user
|
||||
;; but don't create a session or new user
|
||||
(do
|
||||
(create-auth-identity-for-link! (:id @authenticated-user) (:provider-id user-data))
|
||||
(assoc request :success? true))))
|
||||
|
||||
(methodical/defmethod auth-identity/login! :after :provider/slack-connect
|
||||
[_provider result]
|
||||
(if (= sso-settings/slack-connect-auth-mode-link-only (sso-settings/slack-connect-authentication-mode))
|
||||
(dissoc result :user)
|
||||
result))
|
||||
@@ -318,13 +318,6 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e
|
||||
:getter (fn [] (setting/get-value-of-type :boolean :send-new-sso-user-admin-email?))
|
||||
:setter (fn [send-emails] (setting/set-value-of-type! :boolean :send-new-sso-user-admin-email? send-emails)))
|
||||
|
||||
(defsetting other-sso-enabled?
|
||||
"Are we using an SSO integration other than LDAP or Google Auth? These integrations use the `/auth/sso` endpoint for
|
||||
authorization rather than the normal login form or Google Auth button."
|
||||
:visibility :public
|
||||
:setter :none
|
||||
:getter (fn [] (or (saml-enabled) (jwt-enabled))))
|
||||
|
||||
(defsetting ldap-sync-user-attributes
|
||||
(deferred-tru "Should we sync user attributes when someone logs in via LDAP?")
|
||||
:type :boolean
|
||||
@@ -344,3 +337,93 @@ using, this usually looks like `https://your-org-name.example.com` or `https://e
|
||||
:encryption :no
|
||||
:default "(member={dn})"
|
||||
:audit :getter)
|
||||
|
||||
;;; ------------------------------------------------ Slack Connect ------------------------------------------------
|
||||
|
||||
(defsetting slack-connect-client-id
|
||||
(deferred-tru "Client ID for your Slack app. Get this from https://api.slack.com/apps")
|
||||
:encryption :when-encryption-key-set
|
||||
:export? false
|
||||
:feature :sso-slack
|
||||
:audit :getter)
|
||||
|
||||
(defsetting slack-connect-client-secret
|
||||
(deferred-tru "Client Secret for your Slack app")
|
||||
:encryption :when-encryption-key-set
|
||||
:export? false
|
||||
:sensitive? true
|
||||
:feature :sso-slack
|
||||
:audit :no-value)
|
||||
|
||||
(def slack-connect-auth-mode-sso
|
||||
"Authentication mode for full SSO login."
|
||||
"sso")
|
||||
|
||||
(def slack-connect-auth-mode-link-only
|
||||
"Authentication mode for account linking only (no session creation)."
|
||||
"link-only")
|
||||
|
||||
(defsetting slack-connect-authentication-mode
|
||||
(deferred-tru "Controls whether Slack can be used for SSO login or just account linking. Valid values: \"sso\" (default) or \"link-only\"")
|
||||
:type :string
|
||||
:export? false
|
||||
:default slack-connect-auth-mode-sso
|
||||
:feature :sso-slack
|
||||
:audit :getter
|
||||
:encryption :no
|
||||
:setter (fn [new-value]
|
||||
(when (and new-value
|
||||
(not (contains? #{slack-connect-auth-mode-sso slack-connect-auth-mode-link-only} new-value)))
|
||||
(throw (ex-info (tru "Invalid authentication mode. Must be \"sso\" or \"link-only\"")
|
||||
{:status-code 400})))
|
||||
(setting/set-value-of-type! :string :slack-connect-authentication-mode new-value)))
|
||||
|
||||
(defsetting slack-connect-user-provisioning-enabled
|
||||
(deferred-tru "When a user logs in via Slack Connect, create a Metabase account for them automatically if they don''t have one.")
|
||||
:type :boolean
|
||||
:export? false
|
||||
:default true
|
||||
:feature :sso-slack
|
||||
:getter (fn []
|
||||
(if (= (slack-connect-authentication-mode) slack-connect-auth-mode-link-only)
|
||||
false
|
||||
(setting/get-value-of-type :boolean :slack-connect-user-provisioning-enabled)))
|
||||
:audit :getter)
|
||||
|
||||
(defsetting slack-connect-attribute-team-id
|
||||
(deferred-tru "Slack OIDC claim for the team/workspace ID")
|
||||
:default "https://slack.com/team_id"
|
||||
:export? false
|
||||
:feature :sso-slack
|
||||
:encryption :when-encryption-key-set
|
||||
:audit :getter)
|
||||
|
||||
(defsetting slack-connect-configured
|
||||
(deferred-tru "Are the mandatory Slack Connect settings configured?")
|
||||
:type :boolean
|
||||
:export? false
|
||||
:default false
|
||||
:feature :sso-slack
|
||||
:setter :none
|
||||
:getter (fn [] (boolean
|
||||
(and (slack-connect-client-id)
|
||||
(slack-connect-client-secret)))))
|
||||
|
||||
(defsetting slack-connect-enabled
|
||||
(deferred-tru "Is Slack Connect authentication configured and enabled?")
|
||||
:type :boolean
|
||||
:export? false
|
||||
:default false
|
||||
:feature :sso-slack
|
||||
:audit :getter
|
||||
:getter (fn []
|
||||
(if (slack-connect-configured)
|
||||
(setting/get-value-of-type :boolean :slack-connect-enabled)
|
||||
false)))
|
||||
|
||||
(defsetting other-sso-enabled?
|
||||
"Are we using an SSO integration other than LDAP or Google Auth? These integrations use the `/auth/sso` endpoint for
|
||||
authorization rather than the normal login form or Google Auth button."
|
||||
:visibility :public
|
||||
:setter :none
|
||||
:getter (fn [] (or (saml-enabled) (jwt-enabled) (slack-connect-enabled))))
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
[:attributes {:optional true} [:maybe tenant/Attributes]]
|
||||
[:is_active {:optional true} [:maybe ms/BooleanValue]]])
|
||||
|
||||
(mu/defn- update-tenant!
|
||||
(mu/defn update-tenant!
|
||||
"Updates a tenant, publishing any necessary events after doing so"
|
||||
[tenant-id :- ms/PositiveInt
|
||||
{:keys [is_active] :as tenant} :- UpdateTenantArguments]
|
||||
(when (false? is_active)
|
||||
|
||||
@@ -37,26 +37,37 @@
|
||||
:user/tenant-slug (t2/select-one-fn :slug :model/Tenant :id tenant_id)
|
||||
:tenant-slug/tenant-id (:id existing-tenant)
|
||||
:tenant-slug/slug (:slug existing-tenant)
|
||||
:status-code 403}))
|
||||
|
||||
(and existing-tenant
|
||||
(not (:is_active existing-tenant)))
|
||||
(throw (ex-info "Tenant is not active"
|
||||
{:tenant-slug/tenant-id (:id existing-tenant)
|
||||
:tenant-slug/slug (:slug existing-tenant)
|
||||
:tenant-slug/is-active (:is_active existing-tenant)
|
||||
:status-code 403})))))
|
||||
|
||||
(defn- validate-with-tenants-disabled! [{:keys [user tenant-slug]}]
|
||||
(when (or (:tenant_id user) (not-empty tenant-slug))
|
||||
(throw (ex-info "Tenants and tenant users are disabled." {:status-code 403}))))
|
||||
|
||||
(defn- reactivate-tenant!
|
||||
"Reactivate a disabled tenant and any users that were deactivated along with it."
|
||||
[{tenant-id :id}]
|
||||
(api.tenants/update-tenant! tenant-id {:is_active true}))
|
||||
|
||||
(defn- maybe-reactivate-tenant!
|
||||
"If the tenant is disabled and user provisioning is enabled,
|
||||
reactivate the tenant. Otherwise throw an error if the tenant is disabled."
|
||||
[existing-tenant user-provisioning-enabled?]
|
||||
(when (and existing-tenant (not (:is_active existing-tenant)))
|
||||
(if user-provisioning-enabled?
|
||||
(reactivate-tenant! existing-tenant)
|
||||
(throw (ex-info "Tenant is not active"
|
||||
{:tenant-slug/tenant-id (:id existing-tenant)
|
||||
:tenant-slug/slug (:slug existing-tenant)
|
||||
:tenant-slug/is-active (:is_active existing-tenant)
|
||||
:status-code 403})))))
|
||||
|
||||
(defn- create-tenant-if-not-exists!
|
||||
[{:as request :keys [user tenant-slug]} existing-tenant]
|
||||
[{:as request :keys [user tenant-slug user-provisioning-enabled?]} existing-tenant]
|
||||
(if-not (setting/get :use-tenants)
|
||||
(do (validate-with-tenants-disabled! request)
|
||||
request)
|
||||
(do (validate-user-and-tenant-slug! user existing-tenant (boolean tenant-slug))
|
||||
(maybe-reactivate-tenant! existing-tenant user-provisioning-enabled?)
|
||||
(cond-> request
|
||||
(boolean tenant-slug)
|
||||
(assoc-in [:user-data :tenant_id]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
(ns metabase-enterprise.transforms.api
|
||||
(:require
|
||||
[macaw.core :as macaw]
|
||||
[metabase-enterprise.transforms.api.transform-job]
|
||||
[metabase-enterprise.transforms.api.transform-tag]
|
||||
[metabase-enterprise.transforms.canceling :as transforms.canceling]
|
||||
@@ -20,6 +19,7 @@
|
||||
[metabase.driver.sql-jdbc.execute :as sql-jdbc.execute]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.query-processor.compile :as qp.compile]
|
||||
[metabase.query-processor.schema :as qp.schema]
|
||||
[metabase.request.core :as request]
|
||||
@@ -31,8 +31,10 @@
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[ring.util.response :as response]
|
||||
[toucan2.core :as t2])
|
||||
;; TODO (Chris 2026-01-22) -- Remove jsqlparser imports/typehints to be SQL parser-agnostic
|
||||
(:import
|
||||
(java.sql PreparedStatement)
|
||||
^{:clj-kondo/ignore [:metabase/no-jsqlparser-imports]}
|
||||
(net.sf.jsqlparser.statement.select PlainSelect)))
|
||||
|
||||
(comment metabase-enterprise.transforms.api.transform-job/keep-me
|
||||
@@ -122,6 +124,83 @@
|
||||
[:tag_ids {:optional true} [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]]
|
||||
(get-transforms query-params))
|
||||
|
||||
(defn- extract-all-columns-from-query
|
||||
"Extracts column metadata (name and type) from a query.
|
||||
|
||||
Returns a sequence of maps with `:name` and `:base_type` keys, or nil if extraction fails.
|
||||
|
||||
The query is first compiled to native SQL, then uses PreparedStatement.getMetaData()
|
||||
to inspect the query structure. This works for most modern JDBC drivers but may not
|
||||
be supported by all drivers or for all query types."
|
||||
[driver database-id query]
|
||||
(try
|
||||
(let [{:keys [query]} (qp.compile/compile query)]
|
||||
(sql-jdbc.execute/do-with-connection-with-options
|
||||
driver
|
||||
database-id
|
||||
{}
|
||||
(fn [conn]
|
||||
(with-open [^PreparedStatement stmt (sql-jdbc.execute/prepared-statement driver conn query [])]
|
||||
(when-let [rsmeta (.getMetaData stmt)]
|
||||
(seq (sql-jdbc.execute/column-metadata driver rsmeta)))))))
|
||||
(catch Exception e
|
||||
(log/debugf e "Failed to extract columns from query: %s" (ex-message e))
|
||||
nil)))
|
||||
|
||||
(defn- extract-incremental-filter-columns-from-query
|
||||
"Extracts column names suitable for incremental transform checkpoint filtering.
|
||||
|
||||
This function is specifically for incremental transform checkpoint column selection.
|
||||
It only returns columns with types supported for checkpoint filtering:
|
||||
- Temporal types (timestamp, timestamp with timezone)
|
||||
- Numeric types (integer, float, decimal)
|
||||
|
||||
Text, boolean, and other types are filtered out as they are not supported for
|
||||
incremental checkpointing.
|
||||
|
||||
Returns a vector of column names (as strings), or nil if extraction fails.
|
||||
|
||||
The query is first compiled to native SQL, then uses PreparedStatement.getMetaData()
|
||||
to inspect the query structure. This works for most modern JDBC drivers but may not
|
||||
be supported by all drivers or for all query types."
|
||||
[driver database-id query]
|
||||
(some->> (extract-all-columns-from-query driver database-id query)
|
||||
(filter (comp transforms.util/supported-incremental-filter-type? :base_type))
|
||||
(mapv :name)))
|
||||
|
||||
(defn- validate-incremental-column-type!
|
||||
"Validates that the checkpoint column for an incremental transform has a supported type.
|
||||
|
||||
For MBQL/Python transforms, resolves the column from the query using the unique key.
|
||||
For native queries, extracts columns from the query and checks the checkpoint-filter column.
|
||||
|
||||
Throws a 400 error if the column type is not supported or cannot be resolved."
|
||||
[{:keys [source]}]
|
||||
(when-let [{:keys [checkpoint-filter checkpoint-filter-unique-key] strategy-type :type}
|
||||
(:source-incremental-strategy source)]
|
||||
(when (and (= :query (:type source)) (= "checkpoint" strategy-type))
|
||||
(let [{:keys [query]} source
|
||||
database-id (:database query)
|
||||
database (api/check-404 (t2/select-one :model/Database :id database-id))
|
||||
driver-name (driver/the-initialized-driver (:engine database))]
|
||||
(cond
|
||||
;; For MBQL, resolve column from query metadata
|
||||
checkpoint-filter-unique-key
|
||||
(let [column (lib/column-with-unique-key query checkpoint-filter-unique-key)]
|
||||
(api/check-400 column (deferred-tru "Checkpoint column not found in query."))
|
||||
(api/check-400 (transforms.util/supported-incremental-filter-type? (:base-type column))
|
||||
(deferred-tru "Checkpoint column type {0} is not supported. Only numeric and temporal types are supported for incremental filtering."
|
||||
(pr-str (:base-type column)))))
|
||||
|
||||
;; For native query with checkpoint-filter, validate type if we can extract the column metadata
|
||||
checkpoint-filter
|
||||
(when-some [column-metadata (seq (extract-all-columns-from-query driver-name database-id query))]
|
||||
(when-some [column (first (filter #(= checkpoint-filter (:name %)) column-metadata))]
|
||||
(api/check-400 (transforms.util/supported-incremental-filter-type? (:base_type column))
|
||||
(deferred-tru "Checkpoint column ''{0}'' has unsupported type {1}. Only numeric and temporal columns are supported for incremental filtering."
|
||||
checkpoint-filter
|
||||
(pr-str (:base_type column)))))))))))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
@@ -140,9 +219,10 @@
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:owner_user_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:owner_email {:optional true} [:maybe :string]]]]
|
||||
(api/check-superuser)
|
||||
(api/create-check :model/Transform body)
|
||||
(check-database-feature body)
|
||||
(check-feature-enabled! body)
|
||||
(validate-incremental-column-type! body)
|
||||
|
||||
(api/check (not (transforms.util/target-table-exists? body))
|
||||
403
|
||||
@@ -168,8 +248,7 @@
|
||||
(defn get-transform
|
||||
"Get a specific transform."
|
||||
[id]
|
||||
(api/check-superuser)
|
||||
(let [{:keys [target] :as transform} (api/check-404 (t2/select-one :model/Transform id))
|
||||
(let [{:keys [target] :as transform} (api/read-check :model/Transform id)
|
||||
target-table (transforms.util/target-table (transforms.i/target-db-id transform) target :active true)]
|
||||
(-> transform
|
||||
(t2/hydrate :last_run :transform_tag_ids :creator :owner)
|
||||
@@ -195,9 +274,8 @@
|
||||
"Get the dependencies of a specific transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(api/read-check :model/Transform id)
|
||||
(let [id->transform (t2/select-pk->fn identity :model/Transform)
|
||||
_ (api/check-404 (get id->transform id))
|
||||
global-ordering (transforms.ordering/transform-ordering (vals id->transform))
|
||||
dep-ids (get global-ordering id)
|
||||
dependencies (map id->transform dep-ids)]
|
||||
@@ -249,7 +327,7 @@
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:owner_user_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:owner_email {:optional true} [:maybe :string]]]]
|
||||
(api/check-superuser)
|
||||
(api/write-check :model/Transform id)
|
||||
(let [transform (t2/with-transaction [_]
|
||||
;; Cycle detection should occur within the transaction to avoid race
|
||||
(let [old (t2/select-one :model/Transform id)
|
||||
@@ -258,6 +336,7 @@
|
||||
;; we must validate on a full transform object
|
||||
(check-feature-enabled! new)
|
||||
(check-database-feature new)
|
||||
(validate-incremental-column-type! new)
|
||||
(when (transforms.util/query-transform? old)
|
||||
(when-let [{:keys [cycle-str]} (transforms.ordering/get-transform-cycle new)]
|
||||
(throw (ex-info (str "Cyclic transform definitions detected: " cycle-str)
|
||||
@@ -282,8 +361,7 @@
|
||||
"Delete a transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(let [transform (api/check-404 (t2/select-one :model/Transform id))]
|
||||
(let [transform (api/write-check :model/Transform id)]
|
||||
(t2/delete! :model/Transform id)
|
||||
(events/publish-event! :event/transform-delete
|
||||
{:object transform
|
||||
@@ -298,7 +376,7 @@
|
||||
"Delete a transform's output table."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(api/write-check :model/Transform id)
|
||||
(transforms.util/delete-target-table-by-id! id)
|
||||
nil)
|
||||
|
||||
@@ -310,8 +388,7 @@
|
||||
"Cancel the current run for a given transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(let [transform (api/check-404 (t2/select-one :model/Transform id))
|
||||
(let [transform (api/write-check :model/Transform id)
|
||||
run (api/check-404 (transform-run/running-run-for-transform-id id))]
|
||||
(transform-run-cancelation/mark-cancel-started-run! (:id run))
|
||||
(when (transforms.util/python-transform? transform)
|
||||
@@ -326,8 +403,7 @@
|
||||
"Run a transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(let [transform (api/check-404 (t2/select-one :model/Transform id))
|
||||
(let [transform (api/write-check :model/Transform id)
|
||||
_ (check-feature-enabled! transform)
|
||||
start-promise (promise)]
|
||||
(u.jvm/in-virtual-thread*
|
||||
@@ -343,35 +419,15 @@
|
||||
:run_id run-id})
|
||||
(assoc :status 202)))))
|
||||
|
||||
(defn- extract-columns-from-query
|
||||
"Attempts to extract column names from an MBQL query without executing it.
|
||||
|
||||
Returns a vector of column names (as strings), or nil if extraction fails.
|
||||
|
||||
The query is first compiled to native SQL, hen uses PreparedStatement.getMetaData()
|
||||
to inspect the query structure. This works for most modern JDBC drivers but may not
|
||||
be supported by all drivers or for all query types."
|
||||
[driver database-id query]
|
||||
(try
|
||||
(let [{:keys [query]} (qp.compile/compile query)]
|
||||
(sql-jdbc.execute/do-with-connection-with-options
|
||||
driver
|
||||
database-id
|
||||
{}
|
||||
(fn [conn]
|
||||
(with-open [^PreparedStatement stmt (sql-jdbc.execute/prepared-statement driver conn query [])]
|
||||
(when-let [rsmeta (.getMetaData stmt)]
|
||||
(let [columns (sql-jdbc.execute/column-metadata driver rsmeta)]
|
||||
(seq (mapv :name columns))))))))
|
||||
(catch Exception e
|
||||
(log/debugf e "Failed to extract columns from query: %s" (ex-message e))
|
||||
nil)))
|
||||
|
||||
;; TODO (Chris 2026-01-22) -- Remove jsqlparser typehints to be SQL parser-agnostic
|
||||
(defn- simple-native-query?
|
||||
"Checks if a native SQL query string is simple enough for automatic checkpoint insertion."
|
||||
[sql-string]
|
||||
(try
|
||||
(let [^PlainSelect parsed (macaw/parsed-query sql-string)]
|
||||
;; BEWARE: The API endpoint (caller) does not have info on database engine this query should run on. Hence
|
||||
;; there's no way of providing appropriate [[metabase.driver.util/macaw-options]]. `nil` is best-effort
|
||||
;; adding at least default :non-resserved-words.
|
||||
(let [^PlainSelect parsed (driver.u/parsed-query sql-string nil)]
|
||||
(cond
|
||||
(not (instance? PlainSelect parsed))
|
||||
{:is_simple false
|
||||
@@ -407,16 +463,19 @@
|
||||
(api/check-superuser)
|
||||
(simple-native-query? query))
|
||||
|
||||
;; TODO (Cam 2025-12-04) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
|
||||
(api.macros/defendpoint :post "/extract-columns"
|
||||
"Extract column names from an MBQL query without executing it.
|
||||
:- [:map [:columns [:maybe [:sequential :string]]]]
|
||||
"Extract column names suitable for incremental transform checkpoint filtering.
|
||||
|
||||
Compiles the query to native SQL using [[qp.compile/compile-with-inline-parameters]],
|
||||
This endpoint is specifically for populating the checkpoint column dropdown in
|
||||
incremental transforms. It only returns columns with types supported for checkpoint
|
||||
filtering: temporal (timestamp/tz) and numeric (int/float) types.
|
||||
|
||||
Text, boolean, and other unsupported column types are filtered out.
|
||||
|
||||
The query is compiled to native SQL using [[qp.compile/compile-with-inline-parameters]],
|
||||
which handles parameterized queries with template tags. Then extracts column names
|
||||
using PreparedStatement metadata.
|
||||
and types using PreparedStatement metadata.
|
||||
|
||||
Returns a map with a :columns key containing a vector of column names (strings).
|
||||
If extraction fails, returns nil for :columns."
|
||||
@@ -426,9 +485,9 @@
|
||||
[:query ::qp.schema/any-query]]]
|
||||
(api/check-superuser)
|
||||
(let [database-id (:database query)
|
||||
database (api/check-404 (t2/select-one :model/Database :id database-id))
|
||||
database (api/check-404 (t2/select-one :model/Database :id database-id))
|
||||
driver-name (driver/the-initialized-driver (:engine database))
|
||||
columns (extract-columns-from-query driver-name database-id query)]
|
||||
columns (extract-incremental-filter-columns-from-query driver-name database-id query)]
|
||||
{:columns columns}))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
[metabase-enterprise.transforms.util
|
||||
native-query-transform?
|
||||
python-transform?
|
||||
query-transform?]
|
||||
query-transform?
|
||||
transform-source-database
|
||||
transform-type]
|
||||
[metabase-enterprise.transforms.models.transform-run
|
||||
timeout-run!])
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.remote-sync.core :as remote-sync]
|
||||
[metabase.search.core :as search.core]
|
||||
[metabase.search.ingestion :as search]
|
||||
[metabase.search.spec :as search.spec]
|
||||
@@ -25,10 +26,24 @@
|
||||
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped?]]
|
||||
(derive :model/Transform trait))
|
||||
|
||||
;; Only superusers can access transforms
|
||||
(doto :model/Transform
|
||||
(derive ::mi/read-policy.superuser)
|
||||
(derive ::mi/write-policy.superuser))
|
||||
;; Only superusers can access transforms, and writes/creates are blocked globally in remote-sync read-only mode
|
||||
(defmethod mi/can-read? :model/Transform
|
||||
([_instance]
|
||||
(mi/superuser?))
|
||||
([model pk]
|
||||
(mi/can-read? (t2/select-one model pk))))
|
||||
|
||||
(defmethod mi/can-write? :model/Transform
|
||||
([_instance]
|
||||
(and (mi/superuser?)
|
||||
(remote-sync/transforms-editable?)))
|
||||
([model pk]
|
||||
(mi/can-write? (t2/select-one model pk))))
|
||||
|
||||
(defmethod mi/can-create? :model/Transform
|
||||
[_model _instance]
|
||||
(and (mi/superuser?)
|
||||
(remote-sync/transforms-editable?)))
|
||||
|
||||
(defn- keywordize-source-table-refs
|
||||
"Keywordize keys in source-tables map values (refs are maps, ints pass through)."
|
||||
@@ -270,16 +285,27 @@
|
||||
:tags (serdes/nested :model/TransformTransformTag :transform_id opts)}})
|
||||
|
||||
(defmethod serdes/dependencies "Transform"
|
||||
[{:keys [source tags]}]
|
||||
[{:keys [collection_id source tags]}]
|
||||
(set
|
||||
(concat
|
||||
(when collection_id
|
||||
[[{:model "Collection" :id collection_id}]])
|
||||
(for [{tag-id :tag_id} tags]
|
||||
[{:model "TransformTag" :id tag-id}])
|
||||
(serdes/mbql-deps source))))
|
||||
|
||||
(defmethod serdes/storage-path "Transform" [transform _ctx]
|
||||
(let [{:keys [id label]} (-> transform serdes/path last)]
|
||||
["transforms" (serdes/storage-leaf-file-name id label)]))
|
||||
(defmethod serdes/storage-path "Transform" [transform ctx]
|
||||
;; Path: ["collections" "<nested ... collections>" "transforms" "<entity_id_name>"]
|
||||
;; Use default collection path, then restructure similar to NativeQuerySnippet
|
||||
(let [basis (serdes/storage-default-collection-path transform ctx)
|
||||
file (last basis)
|
||||
colls (->> basis rest (drop-last 2))] ; Drop "collections" at start, and last two elements
|
||||
(concat ["collections"] colls ["transforms" file])))
|
||||
|
||||
(defmethod serdes/required "Transform"
|
||||
[_model id]
|
||||
(when-let [collection-id (t2/select-one-fn :collection_id :model/Transform :id id)]
|
||||
{["Collection" collection-id] {"Transform" id}}))
|
||||
|
||||
(defn- maybe-extract-transform-query-text
|
||||
"Return the query text (truncated to `max-searchable-value-length`) from transform source; else nil.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
(ns metabase-enterprise.transforms.models.transform-tag
|
||||
(:require
|
||||
[metabase.events.core :as events]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.util.i18n :as i18n]
|
||||
@@ -58,3 +59,16 @@
|
||||
(defmethod serdes/storage-path "TransformTag" [tt _ctx]
|
||||
(let [{:keys [id label]} (-> tt serdes/path last)]
|
||||
["transforms" "transform_tags" (serdes/storage-leaf-file-name id label)]))
|
||||
|
||||
;; Event hooks for remote-sync tracking
|
||||
(t2/define-after-insert :model/TransformTag [tag]
|
||||
(events/publish-event! :event/transform-tag-create {:object tag})
|
||||
tag)
|
||||
|
||||
(t2/define-after-update :model/TransformTag [tag]
|
||||
(events/publish-event! :event/transform-tag-update {:object tag})
|
||||
tag)
|
||||
|
||||
(t2/define-before-delete :model/TransformTag [tag]
|
||||
(events/publish-event! :event/transform-tag-delete {:object tag})
|
||||
tag)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
[metabase-enterprise.transforms.util :as transforms.util]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.lib.schema.common :as schema.common]
|
||||
[metabase.query-processor.compile :as qp.compile]
|
||||
[metabase.util.log :as log]
|
||||
@@ -76,13 +75,10 @@
|
||||
(transforms.util/run-cancelable-transform!
|
||||
run-id driver transform-details
|
||||
(fn [_cancel-chan] (driver/run-transform! driver transform-details opts))))
|
||||
(transforms.instrumentation/with-stage-timing [run-id [:import :table-sync]]
|
||||
(transforms.util/sync-target! target database)
|
||||
;; This event must be published only after the sync is complete - the new table needs to be in AppDB.
|
||||
(events/publish-event! :event/transform-run-complete {:object transform-details}))
|
||||
;; Creating an index after sync means the filter column is known in the appdb.
|
||||
;; The index would be synced the next time sync runs, but at time of writing, index sync is disabled.
|
||||
(transforms.util/execute-secondary-index-ddl-if-required! transform run-id database target)))
|
||||
(transforms.util/handle-transform-complete!
|
||||
:run-id run-id
|
||||
:transform transform
|
||||
:db database)))
|
||||
(catch Throwable t
|
||||
(log/error t "Error executing transform")
|
||||
(when start-promise
|
||||
|
||||
@@ -75,8 +75,13 @@
|
||||
["table" ::table-target]
|
||||
["table-incremental" ::table-incremental-target]])
|
||||
|
||||
(mr/def ::id pos-int?)
|
||||
|
||||
(mr/def ::run-id pos-int?)
|
||||
|
||||
(mr/def ::transform
|
||||
[:map
|
||||
[:id ::id]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:name :string]
|
||||
[:source [:ref ::transform-source]]
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
[metabase-enterprise.transforms.instrumentation :as transforms.instrumentation]
|
||||
[metabase-enterprise.transforms.interface :as transforms.i]
|
||||
[metabase-enterprise.transforms.models.transform-run :as transform-run]
|
||||
[metabase-enterprise.transforms.schema :as transforms.schema]
|
||||
[metabase-enterprise.transforms.settings :as transforms.settings]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.sql.query-processor :as sql.qp]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
@@ -48,10 +50,15 @@
|
||||
(keyword schema name)
|
||||
(keyword name)))
|
||||
|
||||
(defn transform-type
|
||||
"Get the type of a transform"
|
||||
[transform]
|
||||
(-> transform :source :type keyword))
|
||||
|
||||
(defn query-transform?
|
||||
"Check if this is a query transform: native query / mbql query."
|
||||
[transform]
|
||||
(= :query (-> transform :source :type keyword)))
|
||||
(= :query (transform-type transform)))
|
||||
|
||||
(defn native-query-transform?
|
||||
"Check if this is a native query transform.
|
||||
@@ -64,7 +71,14 @@
|
||||
(defn python-transform?
|
||||
"Check if this is a Python transform."
|
||||
[transform]
|
||||
(= :python (-> transform :source :type keyword)))
|
||||
(= :python (transform-type transform)))
|
||||
|
||||
(defn transform-source-database
|
||||
"Get the source database from a transform"
|
||||
[transform]
|
||||
(case (transform-type transform)
|
||||
:query (-> transform :source :query :database)
|
||||
:python (-> transform :source :source-database)))
|
||||
|
||||
(defn normalize-transform
|
||||
"Normalize a transform's source query, similar to how transforms are normalized when read from the database.
|
||||
@@ -214,6 +228,14 @@
|
||||
[source]
|
||||
(= :checkpoint (some-> source :source-incremental-strategy :type keyword)))
|
||||
|
||||
(defn supported-incremental-filter-type?
|
||||
"Returns true if the given base-type is supported for incremental filtering.
|
||||
|
||||
We only support temporal (timestamp/tz) and numeric (int/float) types."
|
||||
[base-type]
|
||||
(or (isa? base-type :type/Temporal)
|
||||
(isa? base-type :type/Number)))
|
||||
|
||||
(defn- source->checkpoint-filter-unique-key
|
||||
"Extract the checkpoint filter column from `query` using the unique key specified in `source-incremental-strategy`."
|
||||
[query source-incremental-strategy]
|
||||
@@ -223,15 +245,29 @@
|
||||
"Resolve the checkpoint filter column for an incremental transform.
|
||||
|
||||
Tries to resolve the column using the unique key first.
|
||||
Falls back to looking up the column by name from the target table if a `:checkpoint-filter` is specified."
|
||||
Falls back to looking up the column by name from the target table if a `:checkpoint-filter` is specified.
|
||||
|
||||
Validates that the resolved column has a supported type for checkpoint filtering (numeric or temporal).
|
||||
Throws an exception if the column type is not supported."
|
||||
[query source-incremental-strategy table metadata-provider]
|
||||
(or
|
||||
(source->checkpoint-filter-unique-key query source-incremental-strategy)
|
||||
(when-let [field-name (-> source-incremental-strategy :checkpoint-filter)]
|
||||
(when-let [field-id (t2/select-one-pk :model/Field
|
||||
:table_id (:id table)
|
||||
:name field-name)]
|
||||
(lib.metadata/field metadata-provider field-id)))))
|
||||
(let [{:keys [checkpoint-filter checkpoint-filter-unique-key]} source-incremental-strategy]
|
||||
(when-some [{column-name :name
|
||||
:keys [base-type]
|
||||
:as column}
|
||||
(cond
|
||||
checkpoint-filter-unique-key
|
||||
(source->checkpoint-filter-unique-key query source-incremental-strategy)
|
||||
checkpoint-filter
|
||||
(when-some [field-id (t2/select-one-pk :model/Field
|
||||
:table_id (:id table)
|
||||
:name checkpoint-filter)]
|
||||
(lib.metadata/field metadata-provider field-id)))]
|
||||
(when-not (supported-incremental-filter-type? base-type)
|
||||
(throw (ex-info (str "Checkpoint column '" column-name "' has unsupported type " (pr-str base-type) ". "
|
||||
"Only numeric and temporal columns are supported for incremental filtering.")
|
||||
{:column-name column-name
|
||||
:base-type base-type})))
|
||||
column)))
|
||||
|
||||
(defn next-checkpoint
|
||||
"Build a query to compute the MAX of the checkpoint column from the target table.
|
||||
@@ -641,3 +677,29 @@
|
||||
(transforms.instrumentation/with-stage-timing [run-id [:import :create-incremental-filter-index]]
|
||||
(log/infof "Creating secondary index %s(%s) for target %s" index-name value (pr-str target))
|
||||
(driver/create-index! driver (:id database) (:schema target) (:name target) index-name [value]))))))
|
||||
|
||||
(mu/defn handle-transform-complete!
|
||||
"Handles followup tasks for when a transform has completed.
|
||||
|
||||
Specifically, this syncs the target db, publishes a `:event/transform-run-complete` event, and potentially updates
|
||||
the target table's index.
|
||||
|
||||
See [[metabase.transforms-util/decide-secondary-index-ddl]] for details on the index handling."
|
||||
[& {:keys [run-id transform db]}
|
||||
:- [:map
|
||||
[:run-id ::transforms.schema/run-id]
|
||||
[:transform ::transforms.schema/transform]
|
||||
[:db [:fn {:error/message "Must a t2 database object"} #(= (t2/model %) :model/Database)]]]]
|
||||
(let [target (:target transform)]
|
||||
(transforms.instrumentation/with-stage-timing [run-id [:import :table-sync]]
|
||||
(sync-target! target db)
|
||||
;; This event must be published only after the sync is complete - the new table needs to be in AppDB.
|
||||
(events/publish-event! :event/transform-run-complete
|
||||
{:object {:db-id (:id db)
|
||||
:transform-id (:id transform)
|
||||
:transform-type (keyword (:type target))
|
||||
:output-schema (:schema target)
|
||||
:output-table (qualified-table-name (:engine db) target)}})
|
||||
;; Creating an index after sync means the filter column is known in the appdb.
|
||||
;; The index would be synced the next time sync runs, but at time of writing, index sync is disabled.
|
||||
(execute-secondary-index-ddl-if-required! transform run-id db target))))
|
||||
|
||||
@@ -377,9 +377,10 @@
|
||||
ex-message-fn #(exceptional-run-message message-log %)
|
||||
result (transforms.instrumentation/with-stage-timing [run-id [:computation :python-execution]]
|
||||
(transforms.util/run-cancelable-transform! run-id driver transform-details run-fn :ex-message-fn ex-message-fn))]
|
||||
(transforms.instrumentation/with-stage-timing [run-id [:import :table-sync]]
|
||||
(transforms.util/sync-target! target db))
|
||||
(transforms.util/execute-secondary-index-ddl-if-required! transform run-id db target)
|
||||
(transforms.util/handle-transform-complete!
|
||||
:run-id run-id
|
||||
:transform transform
|
||||
:db db)
|
||||
{:run_id run-id
|
||||
:result result}))
|
||||
(catch Throwable t
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
(delete-group :crowberto 204 false)))
|
||||
|
||||
(testing "if `advanced-permissions` is enabled"
|
||||
(mt/with-premium-features #{:advanced-permissions}
|
||||
(mt/with-premium-features #{:advanced-permissions :data-studio}
|
||||
(testing "still fails if user is not a manager"
|
||||
(get-groups user 403)
|
||||
(get-one-group user 403 group)
|
||||
@@ -210,8 +210,8 @@
|
||||
:user_id (:id new-user)
|
||||
:is_group_manager true})))))
|
||||
|
||||
(testing "Admin can could view all groups"
|
||||
(is (= (t2/select-fn-set :id :model/PermissionsGroup :is_tenant_group false)
|
||||
(testing "Admin can view all groups with members"
|
||||
(is (= (t2/select-fn-set :group_id :model/PermissionsGroupMembership)
|
||||
(membership->groups-ids (get-membership :crowberto 200))))))))))
|
||||
|
||||
(deftest get-users-api-test
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user