Compare commits

..

8 Commits

Author SHA1 Message Date
Romeo Van Snick
8c72149945 Rename docker-compose.yml to compose.yml for better compatibility with python-runner repo 2025-09-29 21:08:08 +10:00
Romeo Van Snick
350e69e4e9 Add RUN_MODE to python-runner 2025-09-29 21:08:08 +10:00
Romeo Van Snick
ad9e3af2cd Disable limits in local dev 2025-09-29 21:08:08 +10:00
Romeo Van Snick
9bc2f603e2 Remove python runner dir in CI 2025-09-29 21:08:08 +10:00
Romeo Van Snick
b9e1f5774d Set up the python runner bucket 2025-09-29 21:08:07 +10:00
Romeo Van Snick
0e93fb8dbc Add missing setting 2025-09-29 21:08:07 +10:00
Romeo Van Snick
92e9305d20 Import python runner in Cypress docker compose file 2025-09-29 21:08:07 +10:00
Romeo Van Snick
df4a0869b5 Add docker-compose file for python-runner 2025-09-29 21:08:07 +10:00
164 changed files with 598 additions and 8539 deletions

View File

@@ -428,11 +428,6 @@
:api #{metabase.database-routing.core}
:uses #{premium-features}}
dependencies
{:team "Querying"
:api #{metabase.premium-features.core}
:uses #{premium-features}}
driver
{:team "Drivers"
:api #{metabase.driver
@@ -561,11 +556,6 @@
models
util}}
graph
{:team "Querying"
:api #{metabase.graph.core}
:uses #{util}}
indexed-entities
{:team "UX West"
:api #{metabase.indexed-entities.api
@@ -735,7 +725,6 @@
metabase.native-query-snippets.core}
:uses #{api
collections
events
lib
models
permissions
@@ -1727,29 +1716,6 @@
warehouse-schema
warehouses}}
enterprise/dependencies
{:team "Querying"
:api #{metabase-enterprise.dependencies.api
metabase-enterprise.dependencies.core
metabase-enterprise.dependencies.init}
:uses #{analyze
api
app-db
collections
config
driver
events
graph
lib
lib-be
models
native-query-snippets
premium-features
queries
query-processor
task
util}}
enterprise/documents
{:team "UX West"
:api #{metabase-enterprise.documents.api
@@ -1868,7 +1834,6 @@
search
server
settings
store-api
sync
system
task

View File

@@ -19,6 +19,7 @@ while [[ $# -gt 0 ]]; do
done
echo "Cloning python-runner repository..."
rm -r python-runner
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
if [ "$SKIP_START" = false ]; then

View File

@@ -90,7 +90,7 @@
:git/sha "4998a1fdd9d3713d7034d04509e9212204773bf2"
:git/url "https://github.com/eerohele/pp"}
;; TODO: replace back to normal artifact
io.github.metabase/macaw {:mvn/version "0.2.28"} ; Parse native SQL queries
io.github.metabase/macaw {:mvn/version "0.2.27"} ; Parse native SQL queries
io.github.resilience4j/resilience4j-retry ^:antq/exclude ; 2.x do not support Java 8
{:mvn/version "1.7.1"} ; Support for retrying operations
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector

View File

@@ -29,8 +29,6 @@ title: Driver interface changelog
- Added `metabase.driver/type->database-type` multimethod that returns the database type for a given Metabase
type (from the type hierarchy) as a HoneySQL spec. This method handles general Metabase base types.
- Added driver multimethods driver/native-result-metadata, driver/validate-native-query-fields, driver.sql/resolve-field, driver.sql.normalize-unquoted-name, driver.sql.normalize/reserved-literal, driver.sql.references/find-used-fields, driver.sql.references/find-returned-fields, and driver.sql.references/field-references-impl for use with the :dependencies/native feature.
- Added `metabase.driver/insert-from-source!` multimethod that abstracts data insertion from various sources
into existing tables. This multimethod dispatches on both the driver and the data source type
(`:rows` or `:jsonl-file`). It allows drivers to optimize based on the data source type and returns the number

View File

@@ -146,8 +146,6 @@ To define the configuration that applies to every embed on the page, use the `de
- `apiKey: mb_YourAPIKey` (optional, for development only) - another way to preview embeds locally using an API key.
- `fetchRequestToken: () => Promise<{ jwt: string }>` (optional) - you can customize how the SDK fetches the refresh token for JWT authentication by specifying the `fetchRequestToken` function. See [customizing JWT authentication](./sdk/authentication.md#customizing-jwt-authentication).
### Theming
You can specify colors, fonts, spacing, and other appearance options using the `theme` parameter in your embed configuration.

View File

@@ -89,7 +89,7 @@ You can add some middleware in your backend to handle cross-domain requests.
## Customizing JWT authentication
You can customize how the SDK fetches the request token by specifying the `fetchRequestToken` function with the `defineMetabaseAuthConfig` function:
You can customize how the SDK fetches the refresh token by specifying the `fetchRefreshToken` function with the `defineMetabaseAuthConfig` function:
```typescript
{% include_file "{{ dirname }}/snippets/authentication/auth-config-jwt.tsx" snippet="example" %}

View File

@@ -24,7 +24,6 @@ export interface BaseEmbedTestPageOptions {
instanceUrl?: string;
apiKey?: string;
useExistingUserSession?: boolean;
fetchRequestToken?: () => Promise<{ jwt: string }>;
theme?: MetabaseTheme;
preferredAuthMethod?: "jwt" | "saml";
locale?: string;
@@ -40,7 +39,7 @@ export interface BaseEmbedTestPageOptions {
afterEmbed?: string;
};
onVisitPage?(win: Cypress.AUTWindow): void;
onVisitPage?(): void;
}
export interface MetabaseElement {
@@ -268,7 +267,14 @@ export const getNewEmbedConfigurationScript = ({
useExistingUserSession,
preferredAuthMethod,
locale,
}: BaseEmbedTestPageOptions["metabaseConfig"] = {}) => {
}: {
instanceUrl?: string;
theme?: MetabaseTheme;
apiKey?: string;
useExistingUserSession?: boolean;
preferredAuthMethod?: "jwt" | "saml";
locale?: string;
} = {}) => {
const config = {
instanceUrl,
apiKey,

View File

@@ -1,37 +0,0 @@
import type {
ListTransformRunsResponse,
TransformId,
TransformRun,
} from "metabase-types/api";
export function visitTransform(transformId: TransformId) {
cy.visit(`/admin/transforms/${transformId}`);
}
const WAIT_TIMEOUT = 10000;
const WAIT_INTERVAL = 100;
export function waitForTransformRuns(
filter: (runs: TransformRun[]) => boolean,
timeout = WAIT_TIMEOUT,
): Cypress.Chainable {
return cy
.request<ListTransformRunsResponse>("GET", "/api/ee/transform/run")
.then((response) => {
if (filter(response.body.data)) {
return cy.wrap(response);
} else if (timeout > 0) {
cy.wait(WAIT_INTERVAL);
return waitForTransformRuns(filter, timeout - WAIT_INTERVAL);
} else {
throw new Error("Run retry timeout");
}
});
}
export function waitForSucceededTransformRuns() {
waitForTransformRuns(
(runs) =>
runs.length > 0 && runs.every((run) => run.status === "succeeded"),
);
}

View File

@@ -11,19 +11,6 @@ import {
} from "e2e/support/helpers";
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
export const getSignedJwtForUser = async (user = USERS.admin) => {
const secret = new TextEncoder().encode(JWT_SHARED_SECRET);
return new jose.SignJWT({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
exp: Math.round(Date.now() / 1000) + 60 * 10, // 10 minutes expiration
})
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
};
export const mockAuthProviderAndJwtSignIn = (user = USERS.admin) => {
cy.intercept("GET", `${AUTH_PROVIDER_URL}**`, async (req) => {
try {
@@ -39,7 +26,15 @@ export const mockAuthProviderAndJwtSignIn = (user = USERS.admin) => {
return;
}
const jwt = await getSignedJwtForUser(user);
const secret = new TextEncoder().encode(JWT_SHARED_SECRET);
const jwt = await new jose.SignJWT({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
exp: Math.round(Date.now() / 1000) + 60 * 10, // 10 minutes expiration
})
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
req.reply({
statusCode: 200,

View File

@@ -47,7 +47,6 @@ export * from "./e2e-slack-helpers";
export * from "./e2e-snowplow-helpers";
export * from "./e2e-table-metadata-helpers";
export * from "./e2e-token-helpers";
export * from "./e2e-transform-helpers";
export * from "./e2e-ui-elements-helpers";
export * from "./e2e-ui-elements-overflow-helpers";
export * from "./e2e-upload-helpers";

View File

@@ -1036,7 +1036,7 @@ describe("scenarios > admin > datamodel", () => {
column: "New tax",
values: ["2.07", "6.1", "2.9", "6.01", "7.03"],
});
verifyObjectDetailPreview({ rowNumber: 4, row: ["New tax", "2.07"] });
verifyObjectDetailPreview({ index: 4, row: ["New tax", "2.07"] });
cy.log("verify viz");
H.openOrdersTable();
@@ -1429,7 +1429,7 @@ describe("scenarios > admin > datamodel", () => {
column: "New tax",
values: ["2.07", "6.1", "2.9", "6.01", "7.03"],
});
verifyObjectDetailPreview({ rowNumber: 4, row: ["New tax", "2.07"] });
verifyObjectDetailPreview({ index: 4, row: ["New tax", "2.07"] });
cy.log("verify viz");
H.openOrdersTable();
@@ -1571,7 +1571,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["14", "123", "105", "94", "132"],
});
verifyObjectDetailPreview({
rowNumber: 2,
index: 2,
row: ["Remapped Product ID", "14"],
});
@@ -1669,7 +1669,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
rowNumber: 4,
index: 4,
row: ["Rating", "December 31, 1969, 4:00 PM"],
});
@@ -1718,7 +1718,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
rowNumber: 4,
index: 4,
row: ["Rating", "December 31, 1969, 4:00 PM"],
});
@@ -1989,8 +1989,8 @@ describe("scenarios > admin > datamodel", () => {
values: ["2.07", "6.10", "2.90", "6.01", "7.03"],
});
verifyObjectDetailPreview({
rowNumber: 4,
row: ["Tax ($)", "2.07"],
index: 4,
row: ["Tax ($)", "$2.07"],
});
cy.log("change currency");
@@ -2009,8 +2009,8 @@ describe("scenarios > admin > datamodel", () => {
values: ["2.07", "6.10", "2.90", "6.01", "7.03"],
});
verifyObjectDetailPreview({
rowNumber: 4,
row: ["Tax (CA$)", "2.07"],
index: 4,
row: ["Tax (CA$)", "CA$2.07"],
});
cy.log("verify viz");
@@ -2175,7 +2175,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["2.07", "6.1", "2.9", "6.01", "7.03"],
});
verifyObjectDetailPreview({
rowNumber: 4,
index: 4,
row: ["Tax", "2.07"],
});
@@ -2293,7 +2293,7 @@ describe("scenarios > admin > datamodel", () => {
.should("be.visible");
cy.get("@dataset.all").should("have.length", 0);
verifyObjectDetailPreview({
rowNumber: 4,
index: 4,
row: ["Tax", "2.07"],
});
@@ -2509,7 +2509,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["14", "123", "105", "94", "132"],
});
verifyObjectDetailPreview({
rowNumber: 2,
index: 2,
row: ["Product ID", "14"],
});
@@ -2529,7 +2529,7 @@ describe("scenarios > admin > datamodel", () => {
cy.log("verify preview");
verifyObjectDetailPreview({
rowNumber: 2,
index: 2,
row: ["Product ID", "Awesome Concrete Shoes"],
});
verifyTablePreview({
@@ -2670,7 +2670,7 @@ describe("scenarios > admin > datamodel", () => {
cy.log("verify preview");
FieldSection.getPreviewButton().click();
verifyObjectDetailPreview({
rowNumber: 2,
index: 2,
row: ["Product ID", "Awesome Concrete Shoes"],
});
verifyTablePreview({
@@ -2755,7 +2755,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
rowNumber: 3,
index: 3,
row: ["Rating", "Perfecto"],
});
@@ -2846,10 +2846,6 @@ describe("scenarios > admin > datamodel", () => {
'You can only use custom mapping for numerical fields with filtering set to "A list of all values"',
);
cy.log("close popover by clicking on element inside panel");
FieldSection.get().findByText("Field settings").click();
cy.log("open popover");
FieldSection.getFilteringInput().click();
H.popover().findByText("A list of all values").click();
cy.wait("@updateField");
@@ -2902,7 +2898,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
rowNumber: 1,
index: 1,
row: ["User ID", "2023-10-07T01:34:35.462-07:00"],
});
@@ -2956,7 +2952,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["10", "10"],
});
verifyObjectDetailPreview({
rowNumber: 1,
index: 1,
row: ["Json → A", "10"],
});
@@ -3111,7 +3107,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["200%", "300%", "200%", "600%", "500%"],
});
verifyObjectDetailPreview({
rowNumber: 8,
index: 8,
row: ["Quantity", "200%"],
});
});
@@ -3181,7 +3177,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["about 2", "about 3", "about 2", "about 6", "about 5"],
});
verifyObjectDetailPreview({
rowNumber: 8,
index: 8,
row: ["Quantity", "about 2"],
});
@@ -3869,28 +3865,31 @@ function verifyTablePreview({
}
function verifyObjectDetailPreview({
rowNumber,
index,
row,
}: {
rowNumber: number;
index: number;
row: [string, string];
}) {
const [label, value] = row;
const labelIndex = index * 2;
const valueIndex = labelIndex + 1;
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
cy.findAllByTestId("column-name").then(($els) => {
const foundRowIndex = $els
.toArray()
.findIndex((el) => el.textContent?.trim() === label);
cy.findAllByTestId("object-details-table-cell").should((elements) => {
const index = [...elements].findIndex(
(element) => element.textContent?.trim() === label,
);
expect(index).to.eq(labelIndex);
});
expect(rowNumber).to.eq(foundRowIndex);
cy.findAllByTestId("value")
.should("have.length.gte", foundRowIndex)
.eq(foundRowIndex)
.should("contain", value);
cy.findAllByTestId("object-details-table-cell").should((elements) => {
const index = [...elements].findIndex(
(element) => element.textContent?.trim() === value,
);
expect(index).to.eq(valueIndex);
});
}

View File

@@ -6,6 +6,7 @@ import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import type {
CardType,
ListTransformRunsResponse,
PythonTransformTableAliases,
TransformTagId,
} from "metabase-types/api";
@@ -1943,7 +1944,7 @@ describe("scenarios > admin > transforms > jobs", () => {
tag_ids: [tag.id],
});
});
H.waitForSucceededTransformRuns();
waitForRuns();
visitRunListPage();
getContentTable().within(() => {
cy.findAllByText("MBQL transform").should("have.length.gte", 1);
@@ -2960,6 +2961,24 @@ function visitTableQuestion({
);
}
const WAIT_TIMEOUT = 10000;
const WAIT_INTERVAL = 100;
function waitForRuns(timeout = WAIT_TIMEOUT): Cypress.Chainable {
return cy
.request<ListTransformRunsResponse>("GET", "/api/ee/transform/run")
.then((response) => {
if (response.body.data.length > 0) {
return cy.wrap(response);
} else if (timeout > 0) {
cy.wait(WAIT_INTERVAL);
return waitForRuns(timeout - WAIT_INTERVAL);
} else {
throw new Error("Run retry timeout");
}
});
}
function assertTableDoesNotExistError({
targetTable = TARGET_TABLE,
targetSchema = TARGET_SCHEMA,

View File

@@ -1,678 +0,0 @@
const { H } = cy;
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
const { PRODUCTS_ID } = SAMPLE_DATABASE;
H.describeWithSnowplowEE("documents", () => {
beforeEach(() => {
H.restore("postgres-writable");
H.resetTestTable({ type: "postgres", table: "many_schemas" });
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: "Animals" });
H.resetSnowplow();
cy.intercept("PUT", "/api/card/*").as("updateCard");
cy.intercept("PUT", "/api/native-query-snippet/*").as("updateSnippet");
cy.intercept("PUT", "/api/ee/transform/*").as("updateTransform");
});
afterEach(() => {
H.expectNoBadSnowplowEvents();
});
describe("questions", () => {
it("should be able to confirm or cancel breaking changes to a MBQL question", () => {
createMbqlQuestionWithDependentMbqlQuestions();
cy.log("make breaking changes");
H.visitQuestion("@questionId");
H.openNotebook();
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
cy.log("cancel breaking changes");
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
cy.findByText("Base question").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateCard.all").should("have.length", 0);
cy.log("confirm breaking changes");
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().button("Save anyway").click();
cy.wait("@updateCard");
});
it("should be able to navigate to affected questions or their collection", () => {
createMbqlQuestionWithDependentMbqlQuestions();
cy.log("check that we can navigate to the broken question");
H.visitQuestion("@questionId");
H.openNotebook();
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().findByText("Question with fields").click();
confirmDiscardChanges();
H.queryBuilderHeader()
.findByDisplayValue("Question with fields")
.should("be.visible");
cy.log("check that we can navigate to the collection of that question");
cy.go("back");
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Our analytics").click();
});
confirmDiscardChanges();
H.collectionTable().should("be.visible");
cy.log("check that we can navigate to the dashboard of that question");
cy.go("back");
H.getNotebookStep("expression").findByText("Expr").icon("close").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Orders in a dashboard").click();
});
confirmDiscardChanges();
H.dashboardHeader().should("be.visible");
});
it("should not show a confirmation if there are no breaking changes when updating a MBQL question", () => {
createMbqlQuestionWithDependentMbqlQuestions();
H.visitQuestion("@questionId");
H.openNotebook();
H.getNotebookStep("expression").findByText("Expr").click();
H.CustomExpressionEditor.clear().type("2 + 2");
H.popover().button("Update").click();
H.queryBuilderHeader().button("Save").click();
H.modal().button("Save").click();
cy.wait("@updateCard");
});
});
describe("models", () => {
it("should be able to confirm or cancel breaking changes to a SQL model", () => {
createSqlModelWithDependentSqlQuestions();
cy.log("make breaking changes");
cy.get<number>("@modelId").then(H.visitModel);
H.openQuestionActions("Edit query definition");
H.NativeEditor.clear().type("SELECT ID, TITLE FROM PRODUCTS");
H.runNativeQuery();
cy.log("cancel breaking changes");
H.datasetEditBar().button("Save changes").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
cy.findByText("Base model").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateCard.all").should("have.length", 0);
cy.log("confirm breaking changes");
H.datasetEditBar().button("Save changes").click();
H.modal().button("Save anyway").click();
cy.wait("@updateCard");
});
it("should not show a confirmation if there are no breaking changes when updating a SQL model", () => {
createSqlModelWithDependentSqlQuestions();
cy.get<number>("@modelId").then(H.visitModel);
H.openQuestionActions("Edit query definition");
H.NativeEditor.clear().type("SELECT ID, CATEGORY FROM PRODUCTS");
H.runNativeQuery();
H.datasetEditBar().button("Save changes").click();
cy.wait("@updateCard");
});
});
describe("metrics", () => {
it("should be able to confirm or cancel breaking changes to a metric", () => {
createMetricWithDependentMbqlQuestionsAndTransforms();
cy.log("make breaking changes");
cy.get<number>("@metricId").then(H.visitMetric);
H.openQuestionActions("Edit metric definition");
H.getNotebookStep("summarize").findByText("Min of Score").click();
H.popover().within(() => {
cy.icon("chevronleft").click();
cy.findByText("Maximum of ...").click();
cy.findByText("Score").click();
});
cy.log("cancel breaking changes");
cy.findByTestId("edit-bar").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Question with 2 stages").should("be.visible");
cy.findByText("Transform with 2 stages").should("be.visible");
cy.findByText("Question with 1 stage").should("not.exist");
cy.findByText("Transform with 1 stage").should("not.exist");
cy.findByText("Base metric").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateCard.all").should("have.length", 0);
cy.log("confirm breaking changes");
cy.findByTestId("edit-bar").button("Save changes").click();
H.modal().button("Save anyway").click();
cy.wait("@updateCard");
});
it("should not show a warning when a change to a metric is backward-compatible with existing content", () => {
createMetricWithDependentMbqlQuestionsAndTransforms();
cy.get<number>("@metricId").then(H.visitMetric);
H.openQuestionActions("Edit metric definition");
H.getNotebookStep("summarize").findByText("Min of Score").click();
H.popover().findByText("Name").click();
cy.findByTestId("edit-bar").button("Save changes").click();
cy.wait("@updateCard");
});
});
describe("transforms", () => {
it("should be able to confirm or cancel breaking changes to a SQL transform after it was run", () => {
createSqlTransformWithDependentMbqlQuestions();
cy.log("make breaking changes");
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.NativeEditor.clear().type('SELECT name FROM "Schema A"."Animals"');
cy.log("cancel breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
cy.findByText("Base transform").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateTransform.all").should("have.length", 0);
cy.log("confirm breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
cy.findByText("Base transform").should("not.exist");
cy.button("Save anyway").click();
});
cy.wait("@updateTransform");
});
it("should not show a confirmation if there are no breaking changes when updating a SQL transform after it was run", () => {
createSqlTransformWithDependentMbqlQuestions();
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.NativeEditor.clear().type(
'SELECT score, name FROM "Schema A"."Animals"',
);
cy.findByTestId("transform-query-editor").button("Save changes").click();
cy.wait("@updateTransform");
});
it("should be able to confirm or cancel breaking changes to a MBQL transform before it was run", () => {
createMbqlTransformWithDependentSqlTransforms();
cy.log("make breaking changes");
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.getNotebookStep("data").findByLabelText("Pick columns").click();
H.popover().findByLabelText("Score").click();
cy.log("cancel breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Score transform").should("be.visible");
cy.findByText("Name transform").should("not.exist");
cy.findByText("Base transform").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateTransform.all").should("have.length", 0);
cy.log("confirm breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.modal().within(() => {
cy.findByText("Score transform").should("be.visible");
cy.findByText("Name transform").should("not.exist");
cy.button("Save anyway").click();
});
cy.wait("@updateTransform");
});
it("should not show a confirmation if there are no breaking changes when updating a MBQL transform before it was run", () => {
createMbqlTransformWithDependentSqlTransforms();
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.getNotebookStep("data").button("Sort").click();
H.popover().findByText("Score").click();
cy.findByTestId("transform-query-editor").button("Save changes").click();
cy.wait("@updateTransform");
});
});
describe("snippets", () => {
it("should be able to confirm or cancel breaking changes to a snippet", () => {
createSnippetWithDependentQuestionsAndTransforms();
cy.log("make breaking changes");
startEditingSnippet();
H.modal()
.findByLabelText("Enter some SQL here so you can reuse it later")
.clear()
.type("price = 1");
cy.log("cancel breaking changes");
H.modal().within(() => {
cy.button("Save").click();
cy.findByText("Question with snippet").should("be.visible");
cy.findByText("Transform with snippet").should("be.visible");
cy.findByText("ScoreSnippet").should("not.exist");
cy.button("Cancel").click();
});
cy.get("@updateSnippet.all").should("have.length", 0);
cy.log("confirm breaking changes");
H.modal().within(() => {
cy.button("Save").click();
cy.findByText("Question with snippet").should("be.visible");
cy.findByText("Transform with snippet").should("be.visible");
cy.findByText("ScoreSnippet").should("not.exist");
cy.button("Save anyway").click();
});
cy.wait("@updateSnippet");
});
it("should not warn if changes to a snippet are backward compatible", () => {
createSnippetWithDependentQuestionsAndTransforms();
startEditingSnippet();
H.modal().within(() => {
cy.findByLabelText("Enter some SQL here so you can reuse it later")
.clear()
.type("score = 2");
cy.button("Save").click();
});
cy.wait("@updateSnippet");
});
});
});
function createMbqlQuestionWithDependentMbqlQuestions() {
H.createQuestion({
name: "Base question",
query: {
"source-table": PRODUCTS_ID,
expressions: {
Expr: ["+", 1, 1],
},
},
}).then(({ body: card }) => {
cy.wrap(card.id).as("questionId");
H.createQuestion({
name: "Question with fields",
query: {
"source-table": `card__${card.id}`,
filter: [">", ["field", "Expr", { "base-type": "type/Integer" }], 1],
},
dashboard_id: ORDERS_DASHBOARD_ID,
});
H.createQuestion({
name: "Question without fields",
query: {
"source-table": `card__${card.id}`,
},
});
});
}
function createSqlModelWithDependentSqlQuestions() {
H.createNativeQuestion({
name: "Base model",
type: "model",
native: {
query: "SELECT ID, TITLE, CATEGORY FROM PRODUCTS",
"template-tags": {},
},
}).then(({ body: card }) => {
cy.wrap(card.id).as("modelId");
cy.request("POST", `/api/card/${card.id}/query`);
const tagName = `#${card.id}`;
H.createNativeQuestion({
name: "Question with fields",
native: {
query: `SELECT ID, CATEGORY FROM {{#${card.id}}}`,
"template-tags": {
[tagName]: {
id: "10422a0f-292d-10a3-fd90-407cc9e3e20e",
name: tagName,
"display-name": tagName,
type: "card",
"card-id": card.id,
},
},
},
});
H.createNativeQuestion({
name: "Question without fields",
native: {
query: `SELECT COUNT(*) FROM {{#${card.id}}}`,
"template-tags": {
[tagName]: {
id: "10422a0f-292d-10a3-fd90-407cc9e3e20f",
name: tagName,
"display-name": tagName,
type: "card",
"card-id": card.id,
},
},
},
});
});
}
function createMetricWithDependentMbqlQuestionsAndTransforms() {
H.getTableId({ databaseId: WRITABLE_DB_ID, name: "Animals" }).then(
(tableId) => {
H.getFieldId({ tableId, name: "score" }).then((fieldId) => {
H.createQuestion({
name: "Base metric",
type: "metric",
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
aggregation: [["min", ["field", fieldId, null]]],
},
}).then(({ body: metric }) => {
cy.wrap(metric.id).as("metricId");
H.createQuestion({
name: "Question with 1 stage",
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
aggregation: [["metric", metric.id]],
},
});
H.createQuestion({
name: "Question with 2 stages",
database: WRITABLE_DB_ID,
query: {
"source-query": {
"source-table": tableId,
aggregation: [["metric", metric.id]],
breakout: [["field", fieldId, null]],
},
aggregation: [
["avg", ["field", "min", { "base-type": "type/Integer" }]],
],
},
});
H.createTransform({
name: "Transform with 1 stage",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "query",
query: {
"source-table": tableId,
aggregation: [["metric", metric.id]],
},
},
},
target: {
type: "table",
name: "transform_1_stage",
schema: "public",
database: WRITABLE_DB_ID,
},
});
H.createTransform({
name: "Transform with 2 stages",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "query",
query: {
"source-query": {
"source-table": tableId,
aggregation: [["metric", metric.id]],
breakout: [["field", fieldId, null]],
},
aggregation: [
["avg", ["field", "min", { "base-type": "type/Integer" }]],
],
},
},
},
target: {
type: "table",
name: "transform_2_stages",
schema: "public",
database: WRITABLE_DB_ID,
},
});
});
});
},
);
}
function createSqlTransformWithDependentMbqlQuestions() {
const transformTableName = "base_transform";
H.createTransform({
name: "Base transform",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "native",
native: {
query: 'SELECT name, score FROM "Schema A"."Animals"',
"template-tags": {},
},
},
},
target: {
type: "table",
name: transformTableName,
schema: "public",
database: WRITABLE_DB_ID,
},
}).then(({ body: transform }) => {
cy.wrap(transform.id).as("transformId");
cy.request("POST", `/api/ee/transform/${transform.id}/run`);
H.waitForSucceededTransformRuns();
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: transformTableName });
H.getTableId({ databaseId: WRITABLE_DB_ID, name: transformTableName }).then(
(tableId) => {
H.createQuestion({
name: "Question with fields",
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
filter: [
">",
["field", "score", { "base-type": "type/Integer" }],
1,
],
},
});
H.createQuestion({
name: "Question without fields",
database: WRITABLE_DB_ID,
query: {
"source-table": tableId,
},
});
},
);
});
}
function createMbqlTransformWithDependentSqlTransforms() {
H.getTableId({ name: "Animals", databaseId: WRITABLE_DB_ID }).then(
(tableId) => {
H.createTransform(
{
name: "Base transform",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "query",
query: {
"source-table": tableId,
},
},
},
target: {
type: "table",
name: "base_transform",
schema: "public",
database: WRITABLE_DB_ID,
},
},
{ wrapId: true },
);
H.createTransform({
name: "Name transform",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "native",
native: {
query: "SELECT name FROM base_transform",
"template-tags": {},
},
},
},
target: {
type: "table",
name: "name_transform",
schema: "public",
database: WRITABLE_DB_ID,
},
});
H.createTransform({
name: "Score transform",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "native",
native: {
query: "SELECT score FROM base_transform",
"template-tags": {},
},
},
},
target: {
type: "table",
name: "score_transform",
schema: "public",
database: WRITABLE_DB_ID,
},
});
},
);
}
function createSnippetWithDependentQuestionsAndTransforms() {
H.createSnippet({
name: "ScoreSnippet",
content: "score = 1",
}).then(({ body: snippet }) => {
cy.wrap(snippet.id).as("snippetId");
H.createNativeQuestion({
name: "Question with snippet",
database: WRITABLE_DB_ID,
native: {
query:
'SELECT * FROM "Schema A"."Animals" WHERE {{snippet:ScoreSnippet}}',
"template-tags": {
"snippet:ScoreSnippet": {
id: "4b77cc1f-ea70-4ef6-84db-58432fce6928",
name: "snippet:ScoreSnippet",
"display-name": "snippet:ScoreSnippet",
type: "snippet",
"snippet-id": snippet.id,
"snippet-name": snippet.name,
},
},
},
});
H.createTransform({
name: "Transform with snippet",
source: {
type: "query",
query: {
database: WRITABLE_DB_ID,
type: "native",
native: {
query:
'SELECT * FROM "Schema A"."Animals" WHERE {{snippet:ScoreSnippet}}',
"template-tags": {
"snippet:ScoreSnippet": {
id: "4b77cc1f-ea70-4ef6-84db-58432fce6929",
name: "snippet:ScoreSnippet",
"display-name": "snippet:ScoreSnippet",
type: "snippet",
"snippet-id": snippet.id,
"snippet-name": snippet.name,
},
},
},
},
},
target: {
type: "table",
name: "transform_with_snippet",
schema: "public",
database: WRITABLE_DB_ID,
},
});
});
}
function startEditingSnippet() {
H.startNewNativeQuestion();
cy.findByTestId("native-query-editor-action-buttons").icon("snippet").click();
cy.findByTestId("sidebar-content").within(() => {
cy.icon("chevrondown").click({ force: true });
cy.button(/Edit/).click();
});
}
function confirmDiscardChanges() {
H.modal().should("have.length", 2).last().button("Discard changes").click();
}

View File

@@ -2,6 +2,7 @@ name: metabase-e2e
include:
- ../../../snowplow/docker-compose.yml
- ../../../python-runner/compose.yaml
services:
postgres-sample:

View File

@@ -1,7 +1,5 @@
import { USERS } from "e2e/support/cypress_data";
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
import {
getSignedJwtForUser,
mockAuthSsoEndpointForSamlAuthProvider,
stubWindowOpenForSamlPopup,
} from "e2e/support/helpers/embedding-sdk-testing";
@@ -126,62 +124,6 @@ describe("scenarios > embedding > sdk iframe embedding > authentication", () =>
assertDashboardLoaded(frame);
});
it("can login via JWT and a custom fetch request token function", () => {
H.prepareSdkIframeEmbedTest({ enabledAuthMethods: ["jwt"], signOut: true });
const frame = H.loadSdkIframeEmbedTestPage({
onVisitPage: (win) => {
(win as any).metabaseConfig = {
...(win as any).metabaseConfig,
fetchRequestToken: async () => {
const jwt = await getSignedJwtForUser(USERS.admin);
return { jwt };
},
};
},
elements: [
{
component: "metabase-dashboard",
attributes: {
dashboardId: ORDERS_DASHBOARD_ID,
},
},
],
});
assertDashboardLoaded(frame);
});
it("shows error message if login via JWT and a custom fetch request token is failing", () => {
H.prepareSdkIframeEmbedTest({ enabledAuthMethods: ["jwt"], signOut: true });
const frame = H.loadSdkIframeEmbedTestPage({
onVisitPage: (win) => {
(win as any).metabaseConfig = {
...(win as any).metabaseConfig,
fetchRequestToken: async () => {
return { jwt: "" };
},
};
},
elements: [
{
component: "metabase-dashboard",
attributes: {
dashboardId: ORDERS_DASHBOARD_ID,
},
},
],
});
frame.within(() => {
cy.findByTestId("sdk-error-container")
.findByText(/Failed to fetch JWT token/)
.should("exist");
});
});
it("can login via SAML", () => {
mockAuthSsoEndpointForSamlAuthProvider();
H.prepareSdkIframeEmbedTest({ enabledAuthMethods: [], signOut: true });

View File

@@ -689,7 +689,7 @@ describe("issue 23421", () => {
type: "model",
};
const hiddenColumnsModelDetails = {
const hiddenColumnsQuestionDetails = {
native: {
query,
},
@@ -734,17 +734,16 @@ describe("issue 23421", () => {
});
it("`visualization_settings` with hidden columns should not break UI (metabase#23421)", () => {
H.createNativeQuestion(hiddenColumnsModelDetails, {
H.createNativeQuestion(hiddenColumnsQuestionDetails, {
visitQuestion: true,
});
H.openQuestionActions();
H.popover().findByText("Edit query definition").click();
H.NativeEditor.get().should("be.visible").and("contain", query);
H.tableInteractiveHeader().within(() => {
cy.findByText("id").should("be.visible");
cy.findByText("created_at").should("be.visible");
});
cy.findByTestId("visualization-root")
.findByText("Every field is hidden right now")
.should("be.visible");
cy.button("Save changes").should("be.disabled");
});
});

View File

@@ -20,7 +20,6 @@
[metabase-enterprise.content-verification.api.routes]
[metabase-enterprise.database-replication.api :as database-replication.api]
[metabase-enterprise.database-routing.api]
[metabase-enterprise.dependencies.api]
[metabase-enterprise.documents.api]
[metabase-enterprise.email.api]
[metabase-enterprise.embedding-hub.api]
@@ -53,7 +52,6 @@
:ai-entity-analysis (deferred-tru "AI Entity Analysis")
:collection-cleanup (deferred-tru "Collection Cleanup")
:content-translation (deferred-tru "Content translation")
:dependencies (deferred-tru "Dependency Tracking")
:documents (deferred-tru "Documents")
:embedding (deferred-tru "Embedding")
:etl-connections (deferred-tru "ETL Connections")
@@ -106,7 +104,6 @@
(premium-handler :etl-connections)
(premium-handler :etl-connections-pg))
"/database-routing" (premium-handler metabase-enterprise.database-routing.api/routes :database-routing)
"/dependencies" (premium-handler metabase-enterprise.dependencies.api/routes :dependencies)
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
"/email" (premium-handler metabase-enterprise.email.api/routes :cloud-custom-smtp)
"/embedding-hub" (premium-handler metabase-enterprise.embedding-hub.api/routes :embedding)

View File

@@ -12,7 +12,6 @@
[metabase-enterprise.cache.init]
[metabase-enterprise.comments.init]
[metabase-enterprise.database-replication.init]
[metabase-enterprise.dependencies.init]
[metabase-enterprise.documents.init]
[metabase-enterprise.gsheets.init]
[metabase-enterprise.llm.init]

View File

@@ -1,124 +0,0 @@
(ns metabase-enterprise.dependencies.api
(:require
[medley.core :as m]
[metabase-enterprise.dependencies.core :as dependencies]
[metabase.analyze.core :as analyze]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.collections.models.collection.root :as collection.root]
[metabase.lib-be.metadata.jvm :as lib-be.metadata.jvm]
[metabase.lib.metadata :as lib.metadata]
[metabase.models.interface :as mi]
[metabase.native-query-snippets.core :as native-query-snippets]
[metabase.queries.schema :as queries.schema]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(mr/def ::card-body
[:map
[:id {:optional false} ms/PositiveInt]
[:dataset_query {:optional true} [:maybe ms/Map]]
[:type {:optional true} [:maybe ::queries.schema/card-type]]
[:result_metadata {:optional true} [:maybe analyze/ResultsMetadata]]])
(defn- broken-cards-response
[{:keys [card transform]}]
(let [broken-card-ids (keys card)
broken-cards (when (seq broken-card-ids)
(-> (t2/select :model/Card :id [:in broken-card-ids])
(t2/hydrate [:collection :effective_ancestors] :dashboard)))
broken-transform-ids (keys transform)
broken-transforms (when (seq broken-transform-ids)
(t2/select :model/Transform :id [:in broken-transform-ids]))]
{:success (and (empty? broken-card-ids)
(empty? broken-transform-ids))
:bad_cards (into [] (comp (filter (fn [card]
(if (mi/can-read? card)
card
(do (log/warnf "Eliding broken card %d - not readable by the user" (:id card))
nil))))
(map (fn [card]
(-> card
collection.root/hydrate-root-collection
(update :dashboard #(some-> % (select-keys [:id :name])))))))
broken-cards)
:bad_transforms (into [] broken-transforms)}))
(api.macros/defendpoint :post "/check_card"
"Check a proposed edit to a card, and return the card IDs for those cards this edit will break."
[_route-params
_query-params
body :- ::card-body]
(let [database-id (-> body :dataset_query :database)
base-provider (lib-be.metadata.jvm/application-database-metadata-provider database-id)
original (lib.metadata/card base-provider (:id body))
card (-> original
(assoc :dataset-query (:dataset_query body)
:type (:type body (:type original)))
;; Remove the old `:result-metadata` from the card, it's likely wrong now.
(dissoc :result-metadata)
;; But if the request includes `:result_metadata`, use that. It may be from a native card
;; that's been run before saving the card.
(cond-> #_card
(:result_metadata body) (assoc :result-metadata (:result_metadata body))))
edits {:card [card]}
breakages (dependencies/errors-from-proposed-edits base-provider edits)]
(broken-cards-response breakages)))
(mr/def ::transform-body
[:map
[:id {:optional false} ms/PositiveInt]
[:name {:optional true} :string]
[:source {:optional true} [:maybe ms/Map]]
[:target {:optional true} [:maybe ms/Map]]])
(api.macros/defendpoint :post "/check_transform"
"Check a proposed edit to a transform, and return the card, transform, etc. IDs for things that will break."
[_route-params
_query-params
{:keys [id source target] :as _body} :- ::transform-body]
(if (= (keyword (:type source))
:query)
(let [database-id (-> source :query :database)
base-provider (lib-be.metadata.jvm/application-database-metadata-provider database-id)
original (lib.metadata/transform base-provider id)
transform (-> original
(cond-> #_transform source (assoc :source source))
(cond-> #_transform target (assoc :target target)))
edits {:transform [transform]}
breakages (dependencies/errors-from-proposed-edits base-provider edits)]
(broken-cards-response breakages))
;; if this isn't a sql query, just claim it works
{:success true}))
(api.macros/defendpoint :post "/check_snippet"
"Check a proposed edit to a native snippet, and return the cards, etc. which will be broken."
[_route-params
_query-params
{:keys [id content], snippet-name :name}
:- [:map
[:id {:optional false} ms/PositiveInt]
[:name {:optional true} native-query-snippets/NativeQuerySnippetName]
[:content {:optional true} :string]]]
(let [original (t2/select-one :model/NativeQuerySnippet id)
_ (when (and snippet-name
(not= snippet-name (:name original))
(t2/exists? :model/NativeQuerySnippet :name snippet-name))
(throw (ex-info (tru "A snippet with that name already exists. Please pick a different name.")
{:status-code 400})))
snippet (cond-> (m/assoc-some original
:lib/type :metadata/native-query-snippet
:name snippet-name
:content content)
content native-query-snippets/add-template-tags)
breakages (dependencies/errors-from-proposed-edits {:snippet [snippet]})]
(broken-cards-response breakages)))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/dependencies` routes."
(handlers/routes
(api.macros/ns-handler *ns* +auth)))

View File

@@ -1,66 +0,0 @@
(ns metabase-enterprise.dependencies.calculation
(:require
[metabase-enterprise.dependencies.native-validation :as deps.native]
[metabase.lib-be.metadata.jvm :as lib-be.metadata.jvm]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.lib.util :as lib.util]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(defn- upstream-deps:mbql-query [legacy-query]
(lib.util/source-tables-and-cards [legacy-query]))
(defn- upstream-deps:native-query [metadata-provider query]
(let [engine (:engine (lib.metadata/database metadata-provider))
deps (deps.native/native-query-deps engine metadata-provider query)]
;; The deps are in #{{:table 7} ...} form and need conversion to ::upstream-deps form.
(u/group-by ffirst (comp second first) conj #{} deps)))
(mr/def ::upstream-deps
[:map
[:card {:optional true} [:set ::lib.schema.id/card]]
[:table {:optional true} [:set ::lib.schema.id/table]]
[:snippet {:optional true} [:set ::lib.schema.id/snippet]]
[:transform {:optional true} [:set ::lib.schema.id/transform]]])
(defn- upstream-deps:query [metadata-provider legacy-query]
(case (:type legacy-query)
(:query "query") (upstream-deps:mbql-query legacy-query)
(:native "native") (upstream-deps:native-query metadata-provider legacy-query)
(throw (ex-info "Unhandled kind of query" {:query legacy-query}))))
(mu/defn upstream-deps:card :- ::upstream-deps
"Given a Toucan `:model/Card`, return its upstream dependencies as a map from the kind to a set of IDs."
([card] (upstream-deps:card (lib-be.metadata.jvm/application-database-metadata-provider (:database_id card))
card))
([metadata-provider :- ::lib.schema.metadata/metadata-provider
{query :dataset_query :as _toucan-card}]
(upstream-deps:query metadata-provider query)))
(mu/defn upstream-deps:transform :- ::upstream-deps
"Given a Transform (in Toucan form), return its upstream dependencies."
([transform] (upstream-deps:transform nil transform))
([metadata-provider :- [:maybe ::lib.schema.metadata/metadata-provider]
{{:keys [query type]} :source :as transform}]
(if (#{"query" :query} type)
(upstream-deps:query (or metadata-provider
(lib-be.metadata.jvm/application-database-metadata-provider (:database query)))
query)
(do (log/warnf "Don't know how to analyze the deps of Transform %d with source type '%s'" (:id transform) type)
{}))))
(mu/defn upstream-deps:snippet :- ::upstream-deps
"Given a native query snippet, return its upstream dependencies in the usual `{entity-type #{1 2 3}}` format."
[{:keys [template_tags] :as _snippet}]
(let [type->id-key {:card :card-id, :snippet :snippet-id}
dependencies (keep (fn [tag]
(let [entity-type (:type tag)]
(when-let [id-key (type->id-key entity-type)]
(when-let [entity-id (id-key tag)]
[entity-type entity-id]))))
(vals template_tags))]
(u/group-by first second conj #{} dependencies)))

View File

@@ -1,205 +0,0 @@
(ns metabase-enterprise.dependencies.core
"API namespace for the `metabase-enterprise.dependencies` module.
Call [[errors-from-proposed-edits]] to find out what things will break downstream of a set of new/updated entities."
(:require
[metabase-enterprise.dependencies.metadata-provider :as deps.provider]
[metabase-enterprise.dependencies.models.dependency :as deps.graph]
[metabase-enterprise.dependencies.native-validation :as deps.native]
[metabase.graph.core :as graph]
[metabase.lib-be.metadata.jvm :as lib-be.metadata.jvm]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[toucan2.core :as t2]))
(mr/def ::entity-type
[:enum :card :transform :snippet :table])
(mr/def ::updates-map
;; TODO: Make this more specific.
[:map-of ::entity-type [:sequential [:map [:id {:optional true} :int]]]])
(mu/defn- metadata-provider :- ::lib.schema.metadata/metadata-provider
"Constructs a `MetadataProvider` with some pending edits applied.
The edits are *transitive*! Since a card's output columns can depend on its (possibly updated) inputs, all dependents
of editing things are considered edited too.
Note that if `:result-metadata` is present on an updated Card, it **will be used!** The only case where that is
actually useful is with a native query which has been executed, so the caller has driver metadata to give us. Any
old, pre-update `:result-metadata` should be dropped from any other cards in `updated-entities`."
([base-provider :- ::lib.schema.metadata/metadata-provider
updated-entities :- ::updates-map
& {:keys [graph dependents]}]
(let [dependents (or dependents (deps.graph/transitive-dependents graph updated-entities))]
(deps.provider/override-metadata-provider base-provider updated-entities dependents))))
(defmulti check-query-inner
"Given a `MetadataProvider` and a lib query, find any bad refs in that query."
{:arglists '([metadata-provider driver query])}
(fn [_mp _driver query]
(-> (lib/query-stage query 0)
:lib/type)))
(defmethod check-query-inner :mbql.stage/mbql
[_mp _driver query]
(lib/find-bad-refs query))
(defmethod check-query-inner :mbql.stage/native
[mp driver query]
(deps.native/validate-native-query driver mp query))
(defn- check-query
"Given a `MetadataProvider` and a query, find any bad refs in that query."
[mp driver query]
(check-query-inner mp driver (lib/query mp query)))
(defmulti check-entity
"Given a `MetadataProvider`, and entity type, and an entity id, find any bad refs in that entity."
{:arglists '([metadata-provider entity-type entity-id])}
(fn [_mp entity-type _entity-id]
entity-type))
(defmethod check-entity :default
[_mp _entity-type _entity-id]
nil)
(defmethod check-entity :card
[mp _entity-type entity-id]
(let [query (:dataset-query (lib.metadata/card mp entity-id))
driver (:engine (lib.metadata/database mp))]
(check-query mp driver query)))
(defmethod check-entity :transform
[mp _entity-type entity-id]
(let [{{target-schema :schema target-name :name} :target
{:keys [query]} :source
:as _transform} (lib.metadata/transform mp entity-id)
driver (:engine (lib.metadata/database mp))
output-table (some #(when (and (= (:schema %) target-schema)
(= (:name %) target-name))
%)
(lib.metadata/tables mp))
output-fields (lib.metadata/active-fields mp (:id output-table))
{:keys [duplicate-fields]} (reduce (fn [{:keys [seen duplicate-fields]}
{name :name :as field}]
(if (seen name)
{:seen seen
:duplicate-fields (conj duplicate-fields field)}
{:seen (conj seen name)
:duplicate-fields duplicate-fields}))
{:seen #{}}
output-fields)]
(into duplicate-fields (check-query mp driver query))))
(mu/defn- check-query-soundness ;; :- [:map-of ::lib.schema.id/card [:sequential ::lib.schema.mbql-clause/clause]]
"Given a `MetadataProvider` as returned by [[metadata-provider]], scan all its updated entities and their dependents
to check that everything is still sound.
In particular, we're looking for bad clauses with refs to columns which no longer exist, since those queries fail
to compile.
May return false positives: if a card already has a bad ref in it, then it will be returned even if that bad ref has
nothing to do with the updates in the `provider`.
Returns a map `{:card {card-id [bad-ref ...]}, :transform {...}}`. It will be empty, if there are no bad refs
detected."
[provider :- ::lib.schema.metadata/metadata-provider]
(let [overrides (deps.provider/all-overrides provider)
errors (volatile! {})]
(doseq [[entity-type ids] overrides
id ids
:let [bad-refs (check-entity provider entity-type id)]]
(when (seq bad-refs)
(vswap! errors assoc-in [entity-type id] bad-refs)))
@errors))
(defn- group-by-db [deps]
(let [by-db (volatile! {})]
(when (seq (:card deps))
(doseq [[db-id card-ids] (->> (t2/select [:model/Card :id :database_id :card_schema] :id [:in (:card deps)])
(u/group-by :database_id :id conj #{}))]
(vswap! by-db assoc-in [db-id :card] card-ids)))
(when (seq (:table deps))
(doseq [[db-id table-ids] (->> (t2/select [:model/Table :id :db_id] :id [:in (:table deps)])
(u/group-by :db_id :id conj #{}))]
(vswap! by-db assoc-in [db-id :table] table-ids)))
(when (seq (:transform deps))
(doseq [[db-id transform-ids] (->> (t2/select [:model/Transform :id :source] :id [:in (:transform deps)])
(u/group-by #(get-in % [:source :query :database]) :id conj #{}))]
(if db-id
(vswap! by-db assoc-in [db-id :transform] transform-ids)
(log/warnf "Unable to infer database from transforms %s" transform-ids))))
(when (seq (:snippet deps))
;; Copy any snippet deps to each database, since they span them all.
(vswap! by-db update-vals #(assoc % :snippet (:snippet deps))))
@by-db))
;; TODO: (Braden 09/22/2025) More precise schemas for the errors this function returns. Currently they're pretty
;; opaque, since the consumers of this function really care about "working/broken" and not the details of what's wrong
;; with any particular card.
(mu/defn errors-from-proposed-edits :- [:map-of ::entity-type [:map-of :int [:or :boolean [:sequential :any]]]]
"Given a regular `MetadataProvider`, and a map of entity types (`:card`, `:transform`, `:snippet`) to lists of
updated entities, this returns a map of `{entity-type {entity-id [bad-ref ...]}}`.
The 1-arity groups all the dependents by which Database they are part of, and runs the analysis for each of them.
The output is a map: `{entity-type {id [errors...]}}`; an empty map is returned when there are no errors
detected."
([edits :- ::updates-map]
(let [all-deps (deps.graph/transitive-dependents edits)
by-db (group-by-db all-deps)]
(reduce (fn [errors [db-id deps]]
(-> (lib-be.metadata.jvm/application-database-metadata-provider db-id)
(metadata-provider edits :dependents deps)
check-query-soundness
(merge errors)))
{} by-db)))
([base-provider :- ::lib.schema.metadata/metadata-provider
edits :- ::updates-map]
(errors-from-proposed-edits base-provider nil edits))
([base-provider :- ::lib.schema.metadata/metadata-provider
graph :- [:maybe ::graph/graph]
edits :- ::updates-map]
(-> base-provider
(metadata-provider edits :graph graph)
check-query-soundness)))
#_{:clj-kondo/ignore [:unresolved-namespace]}
(comment
;; This should work on any fresh-ish Metabase instance; these are the built-in example questions.
(let [base-mp (lib-be.metadata.jvm/application-database-metadata-provider 39)
transform (lib.metadata/transform base-mp 1)
transform (-> transform
(update-in [:source :query :query :expressions]
;; Replacing the expression Age with Duration - this breaks downstream uses!
update-keys (constantly "Fair Die"))
#_(dissoc :result-metadata))
mp (metadata-provider base-mp {:transform [transform]})]
#_(deps.provider/all-overrides mp)
(check-query-soundness mp))
;; Checking that we can properly group everything by database. Surprisingly fast even fetching everything.
(let [deps {:card (t2/select-fn-set :id :model/Card)
:transform (t2/select-fn-set :id :model/Transform)
:table (t2/select-fn-set :id :model/Table)}]
(group-by-db deps))
(t2/select-fn-vec (juxt :id :name :active :table_id) :model/Field :id [:in [1065 1066 1067 1068 1069]])
(t2/select-one :model/Card :id 124)
(deps.graph/transitive-dependents {:transform [{:id 1}]})
(deps.graph/transitive-dependents {:table [{:id 155}]})
(metabase.premium-features.core/token-features)
(let [card (t2/select-one :model/Card :id 121)
mp (lib-be.metadata.jvm/application-database-metadata-provider (:database_id card))]
(upstream-deps:card mp card)))

View File

@@ -1,4 +0,0 @@
(ns metabase-enterprise.dependencies.init
(:require
[metabase-enterprise.dependencies.models.dependency]
[metabase-enterprise.dependencies.task.backfill]))

View File

@@ -1,271 +0,0 @@
(ns metabase-enterprise.dependencies.metadata-provider
"A `MetadataProvider` wrapper that understands how to update entities, and to lazily compute the new metadata for
updated entities on demand. This is useful when analyzing a tree of dependents, which might depend on each other in
complex ways.
Just to illustrate, imagine I change a card A which is used by a transform B whose output table C is used by a card
D. If I remove a column from card A, and transform B selects all columns, that field needs to disappear from C
before we can correctly analyze card D.
By representing the updated entities as lazy `delay`s, this `MetadataProvider` can support analysis of the
downstream entities in any order."
(:require
[medley.core :as m]
[metabase-enterprise.dependencies.native-validation :as deps.native]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.protocols :as lib.metadata.protocols]
[metabase.util :as u]
[metabase.util.log :as log]
[pretty.core :as pretty]))
(set! *warn-on-reflection* true)
(defn- metadatas [delegate overrides {metadata-type :lib/type
search-name :name
:keys [id card-id table-id]
:as metadata-spec}]
(let [type-overrides (get overrides metadata-type)
metadata-keys (set (or id search-name))]
(log/tracef "OverridingMetadataProvider request for %s" metadata-spec)
(cond
;; For tables, cards, snippets, transforms, and columns, return overrides if present and looking for specific
;; keys and delegate if not.
(and (#{:metadata/table :metadata/card :metadata/transform :metadata/native-query-snippet :metadata/column} metadata-type)
(seq metadata-keys))
(let [overrides (if (seq id)
(select-keys type-overrides id)
(into {} (keep #(when-let [x (deref %)]
(when (search-name (:name x))
[(:name x) %])))
(vals type-overrides)))
override-key (if (seq id) :id :name)
overridden-keys (set (keys overrides))
missing-keys (remove overridden-keys metadata-keys)]
(log/tracef "Overridden keys %s; missing keys %s" (sort overridden-keys) (sort missing-keys))
(cond-> (map deref (vals overrides))
(seq missing-keys)
(concat (lib.metadata.protocols/metadatas delegate {:lib/type metadata-type
override-key (set missing-keys)}))))
;; if fetching all tables or transforms, get every override and everything from the delegate and then dedupe
(and (empty? metadata-keys)
(#{:metadata/table :metadata/transform} metadata-type))
(as-> (vals type-overrides) entities
(into [] (map force) entities)
(into entities (lib.metadata.protocols/metadatas delegate metadata-spec))
(m/distinct-by :id entities))
;; For columns, return overrides if they're present.
(= metadata-type :metadata/column)
(if-let [overridden-columns (cond
card-id (get-in overrides [::card-columns card-id])
table-id (get-in overrides [::table-columns table-id]))]
@overridden-columns
(lib.metadata.protocols/metadatas delegate metadata-spec))
:else (throw (ex-info "Unknown :lib/metadata type for OverridingMetadataProvider" metadata-spec)))))
(declare all-overrides setup-transform! setup-transforms!)
(deftype OverridingMetadataProvider [delegate *overrides]
lib.metadata.protocols/MetadataProvider
(database [_this] (lib.metadata.protocols/database delegate))
(metadatas [this metadata-spec]
(setup-transforms! this)
(metadatas delegate @*overrides metadata-spec))
(setting [_this setting-key]
(lib.metadata.protocols/setting delegate setting-key))
;; Cached things are direct delegation.
lib.metadata.protocols/CachedMetadataProvider
(cached-metadatas [_this metadata-type metadata-ids]
(lib.metadata.protocols/cached-metadatas delegate metadata-type metadata-ids))
(store-metadata! [_this a-metadata]
(lib.metadata.protocols/store-metadata! delegate a-metadata))
(cached-value [_this k not-found]
(lib.metadata.protocols/cached-value delegate k not-found))
(cache-value! [_this k v]
(lib.metadata.protocols/cache-value! delegate k v))
(has-cache? [_this]
(lib.metadata.protocols/has-cache? delegate))
pretty/PrettyPrintable
(pretty [this]
(list `overriding-metadata-provider (all-overrides this) delegate)))
(defmulti add-override
"Given an `OverridingMetadataProvider`, an `entity-type` and that entity, add this entity as an override."
{:arglists '([metadata-provider entity-type id updates-or-nil])}
(fn [_mp entity-type _id _updates]
entity-type))
(defn- inner-mp [^OverridingMetadataProvider mp]
(.delegate mp))
(defn- with-overrides [^OverridingMetadataProvider mp kvs]
(swap! (.*overrides mp)
#(reduce (fn [m [ks v]]
(assoc-in m ks v))
% kvs)))
(defn- get-returned-columns [mp queryable]
(let [query (lib/query mp queryable)]
(if (= (:lib/type (lib/query-stage query 0))
:mbql.stage/mbql)
(lib/returned-columns query)
(deps.native/native-result-metadata (:engine (lib.metadata/database mp))
mp query))))
(defmethod add-override :card [^OverridingMetadataProvider mp _entity-type id updates]
(with-overrides mp
;; If the `updates` contain `:result-metadata`, we want to use that. However, any `:result-metadata` from the
;; inner-mp should be ignored.
{[:metadata/card id] (delay (merge (when id
(-> (inner-mp mp)
(lib.metadata/card id)
(dissoc :result-metadata)))
updates))
;; This uses the outer OMP and so the overrides are visible!
[::card-columns id] (delay (get-returned-columns mp (lib.metadata/card mp id)))}))
(defonce ^:private last-fake-id (atom 2000000000))
(defn- fake-id []
(swap! last-fake-id inc))
(defn- setup-transform!
[^OverridingMetadataProvider mp s+n->table {:keys [source target] :as _transform}]
;; See if the output table exists in the underlying MetadataProvider.
;; - If the table doesn't exist, invent an ID for it.
;; - Always add an override for the table, even if it already existed.
;; - Compare the transform's output columns to the fields that exist in the metadata.
;; - For any newly added fields, invent an ID and add an override for the field.
;; - For any removed fields that used to exist, add an override for their ID that is nil!
;; - Add an internal [::table-columns table-id] with the list of output columns.
;; Note that this assumes that all other overrides are already loaded!
(let [existing-table (get s+n->table [(:schema target) (:name target)])
output-table (or existing-table
(merge (select-keys target [:schema :name])
{:lib/type :metadata/table
:id (fake-id)
:db-id (:id (lib.metadata/database (inner-mp mp)))
:display-name (:name target)
:visibility-type nil}))
existing-cols (when existing-table
(lib/returned-columns (lib/query (inner-mp mp) existing-table)))
output-cols (delay
;; Note that this will analyze the query with any upstream changes included!
(let [new-cols (get-returned-columns mp (:query source))
by-name (m/index-by :lib/desired-column-alias existing-cols)]
(into [] (for [col new-cols
:let [old-col (by-name (:lib/desired-column-alias col))]]
(merge (select-keys col [:display-name
:base-type :effective-type :semantic-type])
{:name (:lib/desired-column-alias col)
:lib/type :metadata/column
:id (or (:id old-col) (fake-id))
:table-id (:id output-table)
:active true})))))
outputs-by-id (delay (m/index-by :id @output-cols))]
;; NOTE: Any newly added output columns can't be looked up directly by ID, with this version.
;; The `output-cols` code above can be adapted to add them to the `*overrides` atom, but YAGNI for now.
(with-overrides mp
(into {[:metadata/table (:id output-table)] (delay output-table)
[::table-columns (:id output-table)] output-cols}
;; For each pre-existing output column, we need to add an override for a direct read by ID.
;; If the column does not exist in the output, return the original with :active false
(map (fn [existing-col]
[[:metadata/column (:id existing-col)]
(delay (or (get @outputs-by-id (:id existing-col))
(assoc existing-col :active false)))]))
existing-cols))))
(defn- setup-transforms! [^OverridingMetadataProvider mp]
(locking mp
(when-not (-> (.*overrides mp) deref ::setup-complete)
(swap! (.*overrides mp) assoc ::setup-complete true)
(let [;; Fetching all the pre-existing tables.
;; TODO: Once Cam's new by-name logic in the `MetadataProvider` lands, we can use that here.
tables (lib.metadata/tables (inner-mp mp))
s+n->table (m/index-by (juxt :schema :name) tables)]
(doseq [transform (-> (.*overrides mp) deref :metadata/transform vals)]
(setup-transform! mp s+n->table (deref transform)))))))
(defmethod add-override :transform [^OverridingMetadataProvider mp _entity-type id updates]
;; Transforms are complicated:
;; 1. Treated as a card on the input side.
;; 2. The returned-columns of that card become the columns of the output table.
;; 3. The output table is constructed from the transform.
;; 4. Its fields are adapted from the transform's input card.
;; All of these can be wrapped in delays and therefore computed lazily on demand.
;;
;; The trouble is that a transform does not know the output table's ID, nor its field IDs.
;; A modified transform might produce a new table, or have new fields or removed fields.
;; The OverridingMetadataProvider has to account for all of this when it returns tables or fields by ID, or gets
;; the column list for a table.
;;
;; This is achieved by putting `[:metadata/transform id]` into overrides for all transforms, and then one-time
;; logic that runs before the first use of the OverridingMetadataProvider as a MetadataProvider will walk all the
;; transforms and make sure their tables etc. are overridden properly.
(let [transform (merge (when id
(lib.metadata/transform (inner-mp mp) id))
updates)]
(with-overrides mp
{;; The transform itself, merged with the incoming updates.
[:metadata/transform id] (delay transform)
#_#_[::transform-columns id] (delay (->> (lib.metadata/transform mp id)
:source
:query
(lib/query mp)
lib/returned-columns))})))
(defmethod add-override :table [^OverridingMetadataProvider mp _entity-type id updates]
(when (seq updates)
(throw (ex-info "Updating a table directly is not supported" {:table-id id, :edited updates})))
(with-overrides mp
{[:metadata/table id] (delay (log/warnf "Can't happen: Table %d dep should be replaced by its transform" id)
(lib.metadata/table (inner-mp mp) id))
[::table-columns id] (delay
(log/warnf "Can't happen: Table %d ::table-columns should be replaced by its transform" id)
(lib.metadata/fields (inner-mp mp) id))}))
(defmethod add-override :snippet [^OverridingMetadataProvider mp _entity-type id updates]
(with-overrides mp
{[:metadata/native-query-snippet id] (delay (merge (when id
(lib.metadata/native-query-snippet (inner-mp mp) id))
(u/normalize-map updates)))}))
(defn all-overrides
"Returns all the overrides by ID, in the same form as the map input to [[with-deps]]:
`{:card [1 2 3], :transform [45 99]}."
[^OverridingMetadataProvider override-metadata-provider]
(-> (.*overrides override-metadata-provider)
deref
(dissoc ::setup-complete)
(update-vals keys)
(update-keys {:metadata/card :card
:metadata/transform :transform
:metadata/native-query-snippet :snippet})
(select-keys [:card :transform :snippet])))
(defn override-metadata-provider
"Given an underlying `MetadataProvider`, wraps it to support in-memory overrides of cards, fields, etc.
Important note: all overrides must be added first, before this is used as a `MetadataProvider`.
The easiest way to do that is to call the 3-arity, which provides the overrides up front."
([metadata-provider]
(->OverridingMetadataProvider metadata-provider (atom {})))
([metadata-provider updated-entities dependent-ids]
(let [^OverridingMetadataProvider omp (override-metadata-provider metadata-provider)]
(doseq [[entity-type updates] updated-entities
updated-entity updates]
(add-override omp entity-type (:id updated-entity) updated-entity))
(doseq [[entity-type dependents] dependent-ids
:let [updated (into #{} (map :id) (get updated-entities entity-type))]
id dependents
:when (not (updated id))]
(add-override omp entity-type id nil))
omp)))

View File

@@ -1,212 +0,0 @@
(ns metabase-enterprise.dependencies.models.dependency
(:require
[clojure.set :as set]
[metabase-enterprise.dependencies.calculation :as deps.calculation]
[metabase.events.core :as events]
[metabase.graph.core :as graph]
[metabase.models.interface :as mi]
[metabase.premium-features.core :as premium-features]
[metabase.util :as u]
[methodical.core :as methodical]
[potemkin :as p]
[toucan2.core :as t2]))
(def current-dependency-analysis-version
"Current version of the dependency analysis logic.
This should be incremented when the dependency analysis logic changes."
1)
(methodical/defmethod t2/table-name :model/Dependency [_model] :dependency)
(derive :model/Dependency :metabase/model)
(t2/deftransforms :model/Dependency
{:from_entity_type mi/transform-keyword
:to_entity_type mi/transform-keyword})
(defn- deps-children [src-type src-id dst-type dst-id key-seq]
;; Group all keys with the same type together, so we make O(types) indexed [[t2/select]] calls, not O(n).
(transduce (map (fn [[entity-type entity-keys]]
(let [deps (t2/select :model/Dependency
src-type entity-type
src-id [:in entity-keys])]
(u/group-by (juxt src-type src-id)
(juxt dst-type dst-id)
conj #{}
deps))))
merge {}
(u/group-by first second key-seq)))
(defn- key-dependents
"Get the dependent entity keys for the entity keys in `entity-keys`.
Entity keys are [entity-type, entity-id] pairs. See [[entity-type->model]]."
[key-seq]
(deps-children :to_entity_type :to_entity_id :from_entity_type :from_entity_id key-seq))
(p/deftype+ DependencyGraph [children-fn]
graph/Graph
(children-of [_this key-seq]
(children-fn key-seq)))
;; NOTE: We can easily construct a graph of upstream dependencies too, if it's useful.
(defn- graph-dependents []
(->DependencyGraph key-dependents))
(defn transitive-dependents
"Given a map of updated entities `{entity-type [{:id 1, ...} ...]}`, return a map of its transitive dependents
as `{entity-type #{4 5 6}}` - that is, a map from downstream entity type to a set of IDs.
Uses the provided `graph`, or defaults to the `:model/Dependency` table in AppDB.
The inputs must be maps containing `:id`; anything without an `:id` is skipped. They could be Toucan entities,
`MetadataProvider` entities, user input, etc.
**Excludes** the input entities from the list of dependents!"
([updated-entities] (transitive-dependents nil updated-entities))
([graph updated-entities]
(let [graph (or graph (graph-dependents))
starters (for [[entity-type updates] updated-entities
entity updates
:when (:id entity)]
[entity-type (:id entity)])]
(->> (graph/transitive graph starters) ; This returns a flat list.
(u/group-by first second conj #{})))))
(defn replace-dependencies
"Replace the dependencies of the entity of type `entity-type` with id `entity-id` with
the ones specified in `dependencies-by-type`. "
[entity-type entity-id dependencies-by-type]
(let [current-dependencies (t2/select [:model/Dependency :id :to_entity_type :to_entity_id]
:from_entity_type entity-type
:from_entity_id entity-id)
to-remove (keep (fn [{:keys [id to_entity_type to_entity_id]}]
(when-not (get-in dependencies-by-type [to_entity_type to_entity_id])
id))
current-dependencies)
current-by-type (-> (group-by :to_entity_type current-dependencies)
(update-vals #(into #{} (map :to_entity_id) %)))
to-add (for [[to-entity-type ids] dependencies-by-type
to-entity-id (set/difference ids (current-by-type to-entity-type))]
{:from_entity_type entity-type
:from_entity_id entity-id
:to_entity_type to-entity-type
:to_entity_id to-entity-id})]
(t2/with-transaction [_conn]
(when (seq to-remove)
(t2/delete! :model/Dependency :id [:in to-remove]))
(when (seq to-add)
(t2/insert! :model/Dependency to-add)))))
;; ## Maintaining the dependency graph
;; The below listens for inserts, updates and deletes of cards, snippets and transforms in order to keep the
;; dependency graph up to date. Transform *runs* are also a trigger, since the transform's output table may be created
;; or changed at that point.
;; ### Cards
(derive ::card-deps :metabase/event)
(derive :event/card-create ::card-deps)
(derive :event/card-update ::card-deps)
(methodical/defmethod events/publish-event! ::card-deps
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/with-transaction [_conn]
(replace-dependencies :card (:id object) (deps.calculation/upstream-deps:card object))
(when (not= (:dependency_analysis_version object) current-dependency-analysis-version)
(t2/update! :model/Card (:id object)
{:dependency_analysis_version current-dependency-analysis-version})))))
(derive ::card-delete :metabase/event)
(derive :event/card-delete ::card-delete)
(methodical/defmethod events/publish-event! ::card-delete
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/delete! :model/Dependency :from_entity_type :card :from_entity_id (:id object))))
;; ### Snippets
(derive ::snippet-deps :metabase/event)
(derive :event/snippet-create ::snippet-deps)
(derive :event/snippet-update ::snippet-deps)
(methodical/defmethod events/publish-event! ::snippet-deps
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/with-transaction [_conn]
(replace-dependencies :snippet (:id object) (deps.calculation/upstream-deps:snippet object))
(when (not= (:dependency_analysis_version object) current-dependency-analysis-version)
(t2/update! :model/NativeQuerySnippet (:id object)
{:dependency_analysis_version current-dependency-analysis-version})))))
(derive ::snippet-delete :metabase/event)
(derive :event/snippet-delete ::snippet-delete)
(methodical/defmethod events/publish-event! ::snippet-delete
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/delete! :model/Dependency :from_entity_type :snippet :from_entity_id (:id object))))
;; ### Transforms
(derive ::transform-deps :metabase/event)
(derive :event/create-transform ::transform-deps)
(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)
downstream-table-ids (t2/select-fn-set :from_entity_id :model/Dependency
:from_entity_type :table
:to_entity_type :transform
:to_entity_id id)
downstream-tables (when (seq downstream-table-ids)
(t2/select :model/Table :id [:in downstream-table-ids]))
outdated-tables (remove (fn [table]
(and (= (:schema table) (:schema target))
(= (:name table) (:name target))
(or (not db-id)
(= db-id (:db_id table)))))
downstream-tables)
not-found-table-ids (remove (into #{} (map :id) downstream-tables)
downstream-table-ids)]
(when-let [outdated-downstream-table-ids (seq (into (set not-found-table-ids)
(map :id) outdated-tables))]
(t2/delete! :model/Dependency
:from_entity_type :table
:from_entity_id [:in outdated-downstream-table-ids]
:to_entity_type :transform
:to_entity_id id))))
(methodical/defmethod events/publish-event! ::transform-deps
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(t2/with-transaction [_conn]
(replace-dependencies :transform (:id object) (deps.calculation/upstream-deps:transform object))
(when (not= (:dependency_analysis_version object) current-dependency-analysis-version)
(t2/update! :model/Transform (:id object) {:dependency_analysis_version current-dependency-analysis-version}))
(drop-outdated-target-dep! object))))
(derive ::transform-delete :metabase/event)
(derive :event/delete-transform ::transform-delete)
(methodical/defmethod events/publish-event! ::transform-delete
[_ {:keys [id]}]
(when (premium-features/has-feature? :dependencies)
;; TODO: (Braden 09/18/2025) Shouldn't we be deleting the downstream deps for dead edges as well as upstream?
(t2/delete! :model/Dependency :from_entity_type :transform :from_entity_id id)))
;; On *executing* a transform, its (freshly synced) output table is made to depend on the transform.
;; (And if the target has changed, the old table's dep on the transform is dropped.)
;; The upstream deps of the transform are not touched - those change only when the transform is edited.
(derive ::transform-run :metabase/event)
(derive :event/transform-run-complete ::transform-run)
(defn- transform-table-deps! [{:keys [db-id output-schema output-table transform-id] :as _details}]
(let [;; output-table is a keyword like :my_schema/my_table
table-name (name output-table)]
(when-let [table-id (t2/select-one-fn :id :model/Table :db_id db-id :schema output-schema :name table-name)]
(replace-dependencies :table table-id {:transform #{transform-id}}))))
(methodical/defmethod events/publish-event! ::transform-run
[_ {:keys [object]}]
(when (premium-features/has-feature? :dependencies)
(transform-table-deps! object)))

View File

@@ -1,38 +0,0 @@
(ns metabase-enterprise.dependencies.native-validation
(:require
[metabase.driver :as driver]
[metabase.lib.core :as lib]
[metabase.query-processor :as qp]))
(defn- compile-query [metadata-provider query]
(->> (lib/query metadata-provider query)
lib/add-parameters-for-template-tags
(qp/compile-query-with-metadata-provider metadata-provider)
:query))
(defn validate-native-query
"Compiles a (native) query and validates that the fields and tables it refers to really exist.
Returns either nil or a list of errors."
[driver metadata-provider query]
(->> (compile-query metadata-provider query)
(driver/validate-native-query-fields driver metadata-provider)))
(defn native-result-metadata
"Compiles a (native) query and calculates its result metadata"
[driver metadata-provider query]
(->> (compile-query metadata-provider query)
(driver/native-result-metadata driver metadata-provider)))
(defn native-query-deps
"Returns the upstream dependencies of a native query, as a set of `{:kind id}` pairs."
[driver metadata-provider query]
(let [compiled (compile-query metadata-provider query)]
(into (driver/native-query-deps driver compiled metadata-provider)
(keep #(case (:type %)
"snippet" {:snippet (:snippet-id %)}
:snippet {:snippet (:snippet-id %)}
"card" {:card (:card-id %)}
:card {:card (:card-id %)}
nil))
(-> query :native :template-tags vals))))

View File

@@ -1,191 +0,0 @@
(ns metabase-enterprise.dependencies.task.backfill
"Implements a task that brings all entities with dependencies tracked in the dependency table
(see [[metabase-enterprise.dependencies.models.dependency]]) up to date, that is, makes sure
the dependency table contains fresh entries.
This is done by finding entities whose dependency_analysis_version is less than
[[metabase-enterprise.dependencies.models.dependency/current-dependency-analysis-version]] and
updates this field. In most cases this triggers an event handler that calculates the dependencies
and populates the dependency table. For cards, the event is emitted by the job handler itself,
because the update of the record doesn't trigger the event handler. "
(:require
[clojurewerkz.quartzite.jobs :as jobs]
[clojurewerkz.quartzite.scheduler :as qs]
[clojurewerkz.quartzite.triggers :as triggers]
[environ.core :as env]
[java-time.api :as t]
[metabase-enterprise.dependencies.models.dependency :as models.dependency]
[metabase.config.core :as config]
[metabase.events.core :as events]
[metabase.premium-features.core :as premium-features]
[metabase.task.core :as task]
[metabase.util.log :as log]
[toucan2.core :as t2])
(:import
(java.util Map Set)
(java.util.concurrent ConcurrentHashMap)))
(set! *warn-on-reflection* true)
(defn- current-millis
"Returns the current epoch millis. Uses java-time so that clock settings can be used in tests."
[]
(t/to-millis-from-epoch (t/instant)))
(def ^:private entities [:model/Card :model/Transform :model/NativeQuerySnippet])
;; In-memory state for tracking failed entities
;; Stores {:model/Type {id {:fail-count N :next-retry-timestamp M}}}
(def ^:private retry-state
(zipmap entities (repeatedly #(ConcurrentHashMap.))))
;; Stores {:model/Type #{id1 id2 ...}} for entities that have exceeded MAX_RETRIES
(def ^:private terminally-broken
(zipmap entities (repeatedly #(ConcurrentHashMap/newKeySet))))
(def ^:private MAX_RETRIES 5)
(defn- long-env-value
[env-var-kw default]
(or (parse-long (env/env env-var-kw "")) default))
(defn- get-batch-size []
(long-env-value :mb-dependency-backfill-batch-size 20))
(defn- get-delay-minutes []
(long-env-value :mb-dependency-backfill-delay-minutes 60))
(defn- get-variance-minutes []
(long-env-value :mb-dependency-backfill-variance-minutes 10))
(defn- get-processable-ids [model-kw batch-size]
(let [target-version models.dependency/current-dependency-analysis-version
terminally-broken-ids ^Set (terminally-broken model-kw)
retry-state-map ^Map (retry-state model-kw)
current-time (current-millis)]
(into []
(comp
(map :id)
(filter (fn [id]
(and (not (.contains terminally-broken-ids id))
(let [entity-retry-info (.get retry-state-map id)]
(or (nil? entity-retry-info)
(>= current-time (:next-retry-timestamp entity-retry-info)))))))
(take batch-size))
(t2/reducible-select [model-kw :id] :dependency_analysis_version [:< target-version]))))
(defn- backfill-card!
[id target-version]
;; We don't want to change the card at all, we just want to update the dependency data and
;; mark the card as processed for this dependency analysis version.
(let [update-count (t2/update! :model/Card id :dependency_analysis_version [:< target-version]
{:dependency_analysis_version target-version})]
(when-let [card (and (pos? update-count)
(t2/select-one :model/Card id))]
(events/publish-event! :event/card-update {:object card :user-id config/internal-mb-user-id}))
update-count))
(defn- backfill-entity!
[model-kw id target-version]
(t2/with-transaction [_]
(case model-kw
:model/Card (backfill-card! id target-version)
(t2/update! model-kw id :dependency_analysis_version [:< target-version]
{:dependency_analysis_version target-version}))))
(defn- backfill-entity-batch!
[model-kw batch-size]
(let [model-name (name model-kw)
target-version models.dependency/current-dependency-analysis-version
retry-state-map ^Map (retry-state model-kw)
terminally-broken-set ^Set (terminally-broken model-kw)
ids (get-processable-ids model-kw batch-size)] ; Use the new get-processable-ids
(when (seq ids)
(log/infof "Processing a batch of %s %s(s)..." (count ids) model-name))
(reduce (fn [total id]
(+ total
(try
;; this should update the dependency table via a toucan2 update hook
(let [update-count (backfill-entity! model-kw id target-version)]
(.remove retry-state-map id)
update-count)
(catch Exception e
(let [current-time (current-millis)
entity-retry-info (.get retry-state-map id)
failure-count (inc (:fail-count entity-retry-info 0))
retry-minutes (* failure-count (get-delay-minutes))
new-next-retry-timestamp (+ current-time (* retry-minutes 60 1000))]
(if (> failure-count MAX_RETRIES)
(do (log/errorf e "Entity %s %s failed %d times, marking as terminally broken."
model-name id failure-count)
(.add terminally-broken-set id)
(.remove retry-state-map id)) ; Remove from retry map
(do (log/warnf e "Entity %s %s failed, failure count: %d, next retry no sooner than %d minutes."
model-name id failure-count retry-minutes)
(.put retry-state-map id {:fail-count failure-count
:next-retry-timestamp new-next-retry-timestamp}))))
0))))
0
ids)))
(defn- backfill-dependencies
"Job to backfill dependencies for all entities.
Returns true if a full batch has been selected, nil or false otherwise."
[]
(when (premium-features/has-feature? :dependencies)
(-> (reduce (fn [batch-size model-kw]
(if (< batch-size 1)
(reduced 0)
(let [processed (backfill-entity-batch! model-kw batch-size)]
(when (pos? processed)
(log/info "Updated" processed "entities."))
(- batch-size processed))))
(get-batch-size)
entities)
(< 1))))
(defn- has-pending-retries? []
(some (fn [^Map model-retry-state]
(not (.isEmpty model-retry-state)))
(vals retry-state)))
(declare schedule-next-run!)
(task/defjob
^{:doc "Backfill the dependency table."
org.quartz.DisallowConcurrentExecution true}
BackfillDependencies [ctx]
(log/info "Executing BackfillDependencies job...")
(let [full-batch-selected? (backfill-dependencies)
retries? (has-pending-retries?)]
(if (or full-batch-selected?
retries?)
(let [delay-seconds (* (get-delay-minutes) 60)
variance-seconds (* (get-variance-minutes) 60)
delay-in-seconds (max 0 (+ (- delay-seconds variance-seconds) (rand-int (* 2 variance-seconds))))]
(schedule-next-run! delay-in-seconds (.getScheduler ctx)))
(log/info "No more entities to backfill for, stopping."))))
(def ^:private job-key "metabase.task.dependency-backfill.job")
(def ^:private trigger-key "metabase.task.dependency-backfill.trigger")
(defn- schedule-next-run!
([delay-in-seconds] (schedule-next-run! delay-in-seconds nil))
([delay-in-seconds scheduler]
(let [start-at (java.util.Date. (long (+ (current-millis) (* delay-in-seconds 1000))))
trigger (triggers/build
(triggers/with-identity (triggers/key (str trigger-key \. (random-uuid))))
(triggers/for-job job-key)
(triggers/start-at start-at))]
(log/info "Scheduling next run at" start-at)
(if scheduler
;; re-scheduling from the job
(qs/add-trigger scheduler trigger)
;; first schedule
(let [job (jobs/build (jobs/of-type BackfillDependencies) (jobs/with-identity job-key))]
(task/schedule-task! job trigger))))))
(defmethod task/init! ::DependencyBackfill [_]
(if (pos? (get-batch-size))
(schedule-next-run! (rand-int (* (get-variance-minutes) 60)))
(log/info "Not starting dependency backfill job because the batch size is not positive")))

View File

@@ -1,8 +1,6 @@
(ns metabase-enterprise.metabot-v3.api
"`/api/ee/metabot-v3/` routes"
(:require
[clj-http.client :as http]
[clojure.string :as str]
[metabase-enterprise.metabot-v3.api.document]
[metabase-enterprise.metabot-v3.api.metabot]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
@@ -16,11 +14,7 @@
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]
[metabase.app-db.core :as app-db]
[metabase.premium-features.core :as premium-features]
[metabase.store-api.core :as store-api]
[metabase.util :as u]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
@@ -87,24 +81,6 @@
(metabot-v3.context/log body :llm.log/fe->be)
(streaming-request body))
(api.macros/defendpoint :post "/feedback"
"Proxy Metabot feedback to Harbormaster, adding the premium embedding token."
[_route-params
_query-params
feedback :- :map]
(let [token (premium-features/premium-embedding-token)
base-url (store-api/store-api-url)]
(when (or (str/blank? token) (str/blank? base-url))
(throw (ex-info "Cannot build a request. The license token and/or Store api url are missing!" {})))
(try
(http/post (str base-url "/api/v2/metabot/feedback/" token)
{:content-type :json
:body (json/encode feedback)})
api/generic-204-no-content
(catch Exception e
(log/error e "Failed to submit feedback to Harbormaster")
(throw e)))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-v3` routes."
(handlers/routes

View File

@@ -65,7 +65,6 @@
"DashboardBookmark"
"DataPermissions"
"DatabaseRouter"
"Dependency"
"DocumentBookmark"
"HTTPAction"
"ImplicitAction"

View File

@@ -16,7 +16,9 @@
[metabase.sso.core :as sso]
[metabase.util.i18n :refer [tru]]
[ring.util.response :as response]
[toucan2.core :as t2]))
[toucan2.core :as t2])
(:import
(java.net URLEncoder)))
(set! *warn-on-reflection* true)
@@ -97,7 +99,7 @@
(defn- session-data
[jwt {{redirect :return_to} :params, :as request}]
(let [redirect-url (or redirect "/")]
(let [redirect-url (or redirect (URLEncoder/encode "/"))]
(sso-utils/check-sso-redirect redirect-url)
(let [jwt-data (try
(jwt/unsign jwt (sso-settings/jwt-shared-secret)

View File

@@ -4,7 +4,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.util.log :as log]
[metabase.util.malli.registry :as mr]
@@ -36,7 +35,6 @@
{driver :engine :as database} (t2/select-one :model/Database db)
feature (transforms.util/required-database-feature transform)
transform-details {:db-id db
:transform-id id
:transform-type (keyword (:type target))
:conn-spec (driver/connection-spec driver database)
:query (transforms.util/compile-source source)
@@ -58,9 +56,7 @@
run-id driver transform-details
(fn [_cancel-chan] (driver/run-transform! driver transform-details opts)))
(transforms.instrumentation/with-stage-timing [run-id :table-sync]
(transforms.util/sync-target! target database run-id)
;; 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}))))
(transforms.util/sync-target! target database run-id))))
(catch Throwable t
(log/error t "Error executing transform")
(when start-promise

View File

@@ -3,7 +3,6 @@
[clojure.set :as set]
[medley.core :as m]
[metabase-enterprise.transforms.models.transform-run :as transform-run]
[metabase.events.core :as events]
[metabase.models.interface :as mi]
[metabase.models.serialization :as serdes]
[metabase.util :as u]
@@ -18,7 +17,7 @@
(derive :model/Transform trait))
(t2/deftransforms :model/Transform
{:source mi/transform-transform-source
{:source mi/transform-json
:target mi/transform-json
:run_trigger mi/transform-keyword})
@@ -62,18 +61,6 @@
(for [transform transforms]
(assoc transform :tag_ids (vec (get transform-id->tag-ids (:id transform) [])))))))
(t2/define-after-insert :model/Transform [transform]
(events/publish-event! :event/create-transform {:object transform})
transform)
(t2/define-after-update :model/Transform [transform]
(events/publish-event! :event/update-transform {:object transform})
transform)
(t2/define-before-delete :model/Transform [transform]
(events/publish-event! :event/delete-transform {:id (:id transform)})
transform)
(defn update-transform-tags!
"Update the tags associated with a transform using smart diff logic.
Only modifies what has changed: deletes removed tags, updates positions for moved tags,
@@ -147,7 +134,7 @@
(defmethod serdes/make-spec "Transform"
[_model-name opts]
{:copy [:name :description :entity_id]
:skip [:dependency_analysis_version]
:skip []
:transform {:created_at (serdes/date)
:updated_at (serdes/date)
:source {:export serdes/export-mbql :import serdes/import-mbql}

View File

@@ -176,8 +176,8 @@
(defn compile-source
"Compile the source query of a transform."
[{query-type :type :as source}]
(case (keyword query-type)
:query (:query (qp.compile/compile-with-inline-parameters (massage-sql-query (:query source))))))
(case query-type
"query" (:query (qp.compile/compile-with-inline-parameters (massage-sql-query (:query source))))))
(defn required-database-feature
"Returns the database feature necessary to execute `transform`."

View File

@@ -99,6 +99,5 @@
:database_routing true
:cloud_custom_smtp true
:etl_connections false
:etl_connections_pg false
:dependencies false}
:etl_connections_pg false}
(:token-features (mt/user-http-request :crowberto :get 200 "session/properties"))))))

View File

@@ -1,189 +0,0 @@
(ns metabase-enterprise.dependencies.core-test
(:require
[clojure.test :refer [deftest is testing]]
[medley.core :as m]
[metabase-enterprise.dependencies.core :as dependencies]
[metabase-enterprise.dependencies.test-util :as deps.tu]
[metabase.graph.core :as graph]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util :as lib.tu]))
(defn- testbed
"A `MetadataProvider` with a chain of MBQL cards and transforms for testing."
[]
(let [mp (deps.tu/default-metadata-provider)
query1 (-> (lib/query mp (meta/table-metadata :orders))
(lib/expression "Tax Rate" (lib// (meta/field-metadata :orders :tax)
(meta/field-metadata :orders :subtotal))))
mp (lib.tu/metadata-provider-with-card-from-query mp 101 query1)
card1 (lib.metadata/card mp 101)
card1-query (lib/query mp card1)
card1-cols (lib/returned-columns card1-query)
tax-rate (m/find-first #(= (:lib/desired-column-alias %) "Tax Rate") card1-cols)
query2 (-> card1-query
(lib/filter (lib/> tax-rate 0.06)))
mp (lib.tu/metadata-provider-with-card-from-query mp 102 query2)
;; A transform that depends on card1
tf1-query (-> (lib/query mp card1)
lib/->legacy-MBQL)
transform1 {:id 30
:name "MBQL Transform"
:source {:query tf1-query}
:target {:schema "Transformed"
:name "output_tf30"}}
tf1-output (-> (meta/table-metadata :orders)
(assoc :id 1234567
:schema "Transformed"
:name "output_tf30"
:display-name "Transform 30 Output"))
tf1-cols (map-indexed (fn [i col]
(assoc col
:id (+ 123456700 i)
:table-id 1234567
:lib/source :source/table-defaults))
card1-cols)
mp (lib.tu/mock-metadata-provider mp {:tables [tf1-output]
:fields tf1-cols
:transforms [transform1]})
;; An MBQL card that consumes the output table of transform1.
;; References some fields but does not change the output columns.
tf1-consumer (-> (lib/query mp (lib.metadata/table mp 1234567))
(lib/filter (lib/= (m/find-first #(= (:name %) "Tax Rate") tf1-cols)
101)))
mp (lib.tu/metadata-provider-with-card-from-query mp 301 tf1-consumer)
;; SQL cards and snippets
;; Card 7 from [[deps.tu/default-metadata-provider]] uses the "outer" snippet (2).
;; This adds a SQL transform that consumes card 7, and a card that selects from that transform.
tf2-query (lib/native-query mp "SELECT * FROM {{#7}}")
transform2 {:id 31
:name "SQL transform"
:source {:query (lib/->legacy-MBQL tf2-query)}
:target {:schema "Transformed"
:name "output_tf31"}}
tf2-output (-> (meta/table-metadata :orders)
(assoc :id 1234568
:schema "Transformed"
:name "output_tf31"
:display-name "Transform 31 Output"))
;; Output columns for transform2 are derived from card 7. That's "SELECT * FROM {{snippet: outer}}";
;; the outer snippet references {{snippet: inner}}; the inner snippet is {{#card-ref}} to card 1;
;; card 1 is a basic MBQL query of products.
card7 (-> (lib.metadata/card mp 7)
(assoc :result-metadata (:result-metadata (:products/native (lib.tu/mock-cards)))))
mp (lib.tu/mock-metadata-provider mp {:cards [card7]})
tf2-cols (->> card7
(lib/query mp)
lib/returned-columns
(map-indexed (fn [i col]
(assoc col
:id (+ 123456800 i)
:table-id 1234568
:lib/source :source/table-defaults))))
mp (lib.tu/mock-metadata-provider mp {:tables [tf2-output]
:fields tf2-cols
:transforms [transform2]})
;; MBQL card consuming tf2-output.
query3 (-> (lib/query mp tf2-output)
(lib/filter (lib/< (m/find-first #(= (:name %) "RATING") tf2-cols)
3)))
;; SQL card consuming tf2-output.
query4 (lib/native-query mp "SELECT * FROM Transformed.output_tf31")
mp (-> mp
(lib.tu/metadata-provider-with-card-from-query 303 query3)
(lib.tu/metadata-provider-with-card-from-query 304 query4))]
{:provider mp
;; NOTE: This is a *downstream* graph! So it's a map of keys to those entities which depend on them.
:graph (graph/in-memory {[:card 101] #{[:card 102]
[:transform 30]}
[:transform 30] #{[:table 1234567]}
[:table 1234567] #{[:card 301]}
[:snippet 1] #{[:snippet 2]}
[:snippet 2] #{[:card 7]}
[:card 7] #{[:transform 31]}
[:transform 31] #{[:table 1234568]}
[:table 1234568] #{[:card 303]
[:card 304]}})
:mbql-base card1
:mbql-dependent (lib.metadata/card mp 102)
:mbql-transform transform1
:mbql-transform-output tf1-output
:mbql-transform-cols (m/index-by :name tf1-cols)
:mbql-transform-consumer (lib.metadata/card mp 301)
:snippet-inner (lib.metadata/native-query-snippet mp 1)
:snippet-outer (lib.metadata/native-query-snippet mp 2)
:sql-base (lib.metadata/card mp 7)
:sql-transform transform2
:sql-transform-output tf2-output
:sql-transform-cols (m/index-by :name tf2-cols)
:sql-transform-mbql-consumer (lib.metadata/card mp 303)
:sql-transform-sql-consumer (lib.metadata/card mp 304)}))
(comment
*e
(:mbql-dependent (testbed))
(:mbql-transform-consumer (testbed)))
(deftest ^:parallel basic-mbql-card-test
(testing "when changing an MBQL card with dependents"
(let [{:keys [provider graph mbql-base]
{tax-rate "Tax Rate"} :mbql-transform-cols
{downstream-card-id :id} :mbql-dependent
{transformed-card-id :id} :mbql-transform-consumer} (testbed)]
(testing "a column that no longer exists will cause errors when referenced"
(let [card' (-> mbql-base
(update-in [:dataset-query :query :expressions]
update-keys (constantly "Sales Taxes"))
(dissoc :result-metadata))
errors (dependencies/errors-from-proposed-edits provider graph {:card [card']})]
(is (=? {:card {downstream-card-id [[:field {} "Tax Rate"]]
transformed-card-id [[:field {} (:id tax-rate)]]}}
errors))
(is (= [:card] (keys errors)))
(is (= #{downstream-card-id transformed-card-id} (set (keys (:card errors)))))))
(testing "changing something unrelated will cause no errors"
(let [card' (-> mbql-base
(assoc-in [:dataset-query :query :filter]
[:> [:field (meta/id :orders :quantity) nil] 100])
(dissoc :result-metadata))]
(is (= {} (dependencies/errors-from-proposed-edits provider graph {:card [card']}))))))))
(deftest ^:parallel sql-snippet->card->transform->cards-test
(testing "changing a snippet correctly finds downstream errors"
(let [{:keys [provider graph sql-transform snippet-inner sql-transform-cols]
{direct-sql-card-id :id} :sql-base
{transformed-sql-card-id :id} :sql-transform-sql-consumer
{transformed-mbql-card-id :id} :sql-transform-mbql-consumer} (testbed)]
(testing "when breaking the inner snippet with a nonexistent table"
(let [snippet' (assoc snippet-inner
:content "nonexistent_table"
:template-tags {})
rating (get sql-transform-cols "RATING")
errors (dependencies/errors-from-proposed-edits provider graph {:snippet [snippet']})]
;; That breaks (1) the SQL card which uses the snippets, (2) the transforms, (3) both the MBQL and (4) SQL
;; queries that consume the transform's table.
(is (=? {:card {direct-sql-card-id [{:table {:table "NONEXISTENT_TABLE"},
:type :all-columns,
:metabase.driver.sql/bad-reference true}]
transformed-sql-card-id [{:table {:schema "TRANSFORMED", :table "OUTPUT_TF31"},
:type :all-columns,
:metabase.driver.sql/bad-reference true}]
transformed-mbql-card-id [[:field {} (:id rating)]]}
:transform {(:id sql-transform) [{:table {:table "NONEXISTENT_TABLE"},
:type :all-columns,
:metabase.driver.sql/bad-reference true}]}}
errors))
(is (= #{:card :transform}
(set (keys errors))))
(is (= #{direct-sql-card-id transformed-sql-card-id transformed-mbql-card-id}
(set (keys (:card errors)))))
(is (= #{(:id sql-transform)}
(set (keys (:transform errors))))))))))

View File

@@ -1,131 +0,0 @@
(ns metabase-enterprise.dependencies.metadata-provider-test
(:require
[clojure.test :refer [deftest is testing]]
[medley.core :as m]
[metabase-enterprise.dependencies.metadata-provider :as deps.mp]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util :as lib.tu]
[metabase.lib.test-util.macros :as lib.tu.macros]))
(deftest basic-cards-test
(testing "overriding a single card, it is returned correctly"
(let [card (:orders (lib.tu/mock-cards))
mp (-> card
lib.tu/metadata-provider-with-mock-card
deps.mp/override-metadata-provider)]
(deps.mp/add-override mp :card (:id card)
(-> card
(assoc-in [:dataset-query :query :fields]
(lib.tu.macros/$ids orders
[$subtotal $created-at]))
(dissoc :result-metadata)))
(is (=? [(meta/field-metadata :orders :subtotal)
(meta/field-metadata :orders :created-at)]
(->> (lib.metadata/card mp (:id card))
(lib/query mp)
lib/returned-columns))))))
(defn- replace-query [new-query card]
(-> card
(assoc :dataset-query (lib/->legacy-MBQL new-query))
(dissoc :result-metadata)))
(deftest upstream-card-test
(testing "overriding an upstream card that's consumed by a downstream card"
(let [upstream (-> (lib/query meta/metadata-provider (meta/table-metadata :orders))
(lib/breakout (meta/field-metadata :products :category))
(lib/aggregate (lib/count)))
mp1 (lib.tu/metadata-provider-with-card-from-query 1 upstream)
card1 (lib.metadata/card mp1 1)
by-name (m/index-by :name (lib/returned-columns (lib/query mp1 card1)))
downstream (-> (lib/query mp1 card1)
(lib/filter (lib/= (get by-name "count") 1)))
mp (-> (lib.tu/metadata-provider-with-card-from-query mp1 2 downstream)
deps.mp/override-metadata-provider)]
(deps.mp/add-override mp :card 1
(-> upstream
(lib/breakout (lib/with-temporal-bucket (meta/field-metadata :orders :created-at) :day))
(replace-query card1)))
;; NOTE: This line is important: this is what would happen to all the dependents returned by the graph.
;; There's no update (`nil`) but it still includes the downstream stuff in the scope of the
;; OverrideMetadataProvider.
(deps.mp/add-override mp :card 2 nil)
(testing "upstream card's columns are correct"
(is (=? [{:name "CATEGORY"}
{:name "CREATED_AT"}
{:name "count"}]
(->> (lib.metadata/card mp 1)
(lib/query mp)
lib/returned-columns))))
(testing "downstream card (which selects all) is also correct"
(is (=? [{:name "CATEGORY"}
{:name "CREATED_AT"}
{:name "count"}]
(->> (lib.metadata/card mp 2)
(lib/query mp)
lib/returned-columns)))))))
(deftest card->transform->card-test
(testing "changing the columns of an upstream card that is transformed and then used by a card"
(let [upstream (-> (lib/query meta/metadata-provider (meta/table-metadata :orders))
(lib/breakout (meta/field-metadata :products :category))
(lib/aggregate (lib/count)))
mp1 (lib.tu/metadata-provider-with-card-from-query 1 upstream)
card1 (lib.metadata/card mp1 1)
card1-cols (-> (lib/query mp1 card1)
lib/returned-columns)
transform {:lib/type :metadata/transform
:id 123
:name "The Transform"
:source {:type :query
:query (-> (lib/query mp1 (lib.metadata/card mp1 1))
lib/->legacy-MBQL)}
:target {:type "table"
:schema "transform_schema_123"
:name "autobots"}}
mp2 (lib.tu/mock-metadata-provider
mp1 {:tables [{:id 123000000
:lib/type :metadata/table
:name "autobots"
:schema "transform_schema_123"
:db-id (meta/id)
:visibility-type nil
:active true}]
:fields (map-indexed (fn [i col]
(assoc col
:id (+ 123000001 i)
:table-id 123000000))
card1-cols)})
xform-cols (m/index-by :name (lib.metadata/fields mp2 123000000))
downstream (-> (lib/query mp2 (lib.metadata/table mp2 123000000))
(lib/filter (lib/= (get xform-cols "count") 1)))
mp (-> (lib.tu/metadata-provider-with-card-from-query mp2 2 downstream)
deps.mp/override-metadata-provider)]
(deps.mp/add-override mp :card 1
(-> upstream
(lib/remove-clause (first (lib/breakouts upstream)))
(lib/breakout (lib/with-temporal-bucket (meta/field-metadata :orders :created-at) :day))
(replace-query card1)))
;; NOTE: Theses lines are important: this is what would happen to all the dependents returned by the graph.
;; There's no update (`nil`) but it still includes the downstream stuff in the scope of the
;; OverrideMetadataProvider.
(deps.mp/add-override mp :card 2 nil)
(deps.mp/add-override mp :transform 123 transform)
(testing "upstream card's columns are correct"
(is (=? [{:name "CREATED_AT"}
{:name "count"}]
(->> (lib.metadata/card mp 1)
(lib/query mp)
lib/returned-columns))))
(testing "downstream card (which selects all) is also correct"
(is (=? [{:name "CREATED_AT"}
{:name "count"}]
(->> (lib.metadata/card mp 2)
(lib/query mp)
lib/returned-columns
(sort-by :name))))))))

View File

@@ -1,216 +0,0 @@
(ns metabase-enterprise.dependencies.models.dependency-test
(:require
[clojure.set :as set]
[clojure.test :refer [deftest is testing]]
[metabase-enterprise.dependencies.models.dependency :as deps.graph]
[metabase.lib.core :as lib]
[metabase.queries.models.card :as card]
[metabase.test :as mt]
[toucan2.core :as t2]))
(defn- depends-on-> [from-type from-id to-type to-id]
{:from_entity_type from-type
:from_entity_id from-id
:to_entity_type to-type
:to_entity_id to-id})
(defn- upstream-of [from-type from-id]
(t2/select-fn-set #(select-keys % [:from_entity_type :from_entity_id :to_entity_type :to_entity_id])
:model/Dependency :from_entity_type from-type :from_entity_id from-id))
(defn- basic-orders []
{:name "Test card"
:database_id (mt/id)
:table_id (mt/id :orders)
:display :table
:query_type :query
:type :question
:dataset_query (mt/mbql-query orders)
:visualization_settings {}})
(defn- wrap-card [inner-card]
{:name "Downstream card"
:database_id (mt/id)
:display :table
:query_type :query
:type :question
:dataset_query (mt/mbql-query nil
{:source-table (str "card__" (:id inner-card))})
:visualization_settings {}})
(deftest ^:sequential card-deps-maintenance-test-1-new-card
(testing "upstream deps of a card are updated correctly"
(mt/dataset test-data
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
(mt/with-premium-features #{:dependencies}
(let [card1 (card/create-card! (basic-orders) user)]
(is (integer? (:id card1)))
(testing "when creating a new card"
(is (=? #{(depends-on-> :card (:id card1) :table (mt/id :orders))}
(upstream-of :card (:id card1))))
(testing "that depends on another card"
(let [card2 (card/create-card! (wrap-card card1) user)]
(is (=? #{(depends-on-> :card (:id card2) :card (:id card1))}
(upstream-of :card (:id card2))))
(testing "but that doesn't affect the upstream deps of the inner card"
(is (=? #{(depends-on-> :card (:id card1) :table (mt/id :orders))}
(upstream-of :card (:id card1))))))))
(testing "when updating an existing card"
(testing "to add a new table dep"
(card/update-card! {:card-before-update card1
:card-updates
{:dataset_query (mt/mbql-query orders
{:joins [{:alias "Products"
:source-table (mt/id :products)
:condition [:= $id &Products.$products.id]}]})}})
(is (=? #{(depends-on-> :card (:id card1) :table (mt/id :orders))
(depends-on-> :card (:id card1) :table (mt/id :products))}
(upstream-of :card (:id card1)))))
(testing "to remove a table dep"
(card/update-card! {:card-before-update (t2/select-one :model/Card :id (:id card1))
:card-updates
{:dataset_query (mt/mbql-query products)}})
(is (=? #{(depends-on-> :card (:id card1) :table (mt/id :products))}
(upstream-of :card (:id card1))))))))))))
(deftest ^:sequential card-deps-graph-test-1-mbql-card-chain
(testing "deps graph is connected properly for a chain of MBQL cards"
(mt/dataset test-data
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
(mt/with-premium-features #{:dependencies}
(let [{id1 :id :as card1} (card/create-card! (basic-orders) user)
{id2 :id :as card2} (card/create-card! (wrap-card card1) user)
{id3 :id :as card3} (card/create-card! (wrap-card card2) user)]
(testing "raw deps are recorded correctly"
(is (=? #{(depends-on-> :card id1 :table (mt/id :orders))} (upstream-of :card id1)))
(is (=? #{(depends-on-> :card id2 :card id1)} (upstream-of :card id2)))
(is (=? #{(depends-on-> :card id3 :card id2)} (upstream-of :card id3))))
(testing "transitive deps are computed correctly"
(testing "for each card"
(is (=? {:card #{id2 id3}}
(deps.graph/transitive-dependents {:card [card1]})))
(is (=? {:card #{id3}}
(deps.graph/transitive-dependents {:card [card2]})))
(is (=? {}
(deps.graph/transitive-dependents {:card [card3]}))))
(testing "for the table"
(is (set/subset? #{id1 id2 id3}
(-> {:table [{:id (mt/id :orders)}]}
deps.graph/transitive-dependents
:card)))))))))))
(defn- sql-card [metadata-provider sql]
{:name "SQL card"
:database_id (mt/id)
:display :table
:query_type :query
:type :question
:dataset_query (-> (lib/native-query metadata-provider sql)
lib/->legacy-MBQL)
:visualization_settings {}})
(deftest ^:sequential card-deps-graph-test-2-native-card-chain
(testing "deps graph is connected properly for a chain of native cards"
(mt/dataset test-data
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
(mt/with-premium-features #{:dependencies}
(let [mp (mt/metadata-provider)
{id1 :id :as card1} (card/create-card! (sql-card mp "SELECT * FROM orders;") user)
{id2 :id :as card2} (card/create-card! (sql-card mp (str "SELECT * FROM {{#" id1 "}}")) user)
{id3 :id :as card3} (card/create-card! (sql-card mp (str "SELECT * FROM {{#" id2 "}}")) user)]
(testing "raw deps are recorded correctly"
(is (=? #{(depends-on-> :card id1 :table (mt/id :orders))} (upstream-of :card id1)))
(is (=? #{(depends-on-> :card id2 :table (mt/id :orders))
(depends-on-> :card id2 :card id1)} (upstream-of :card id2)))
(is (=? #{(depends-on-> :card id3 :table (mt/id :orders))
(depends-on-> :card id3 :card id2)} (upstream-of :card id3))))
(testing "transitive deps are computed correctly"
(testing "for each card"
(is (=? {:card #{id2 id3}}
(deps.graph/transitive-dependents {:card [card1]})))
(is (=? {:card #{id3}}
(deps.graph/transitive-dependents {:card [card2]})))
(is (=? {}
(deps.graph/transitive-dependents {:card [card3]}))))
(testing "for the table"
(is (set/subset? #{id1 id2 id3}
(-> {:table [{:id (mt/id :orders)}]}
deps.graph/transitive-dependents
:card)))))))))))
(deftest ^:sequential card-deps-graph-metric-test
(testing "deps graph is connected properly for a question using a metric"
(mt/dataset test-data
(mt/with-temp [:model/User user {:email "me@wherever.com"}]
(mt/with-premium-features #{:dependencies}
(let [metric-card (card/create-card! {:name "Test Metric"
:database_id (mt/id)
:table_id (mt/id :orders)
:display :scalar
:query_type :query
:type :metric
:dataset_query (mt/mbql-query orders {:aggregation [[:count]]})
:visualization_settings {}}
user)
question-card (card/create-card! {:name "Question using Metric"
:database_id (mt/id)
:display :table
:query_type :query
:type :question
:dataset_query (mt/mbql-query orders
{:aggregation [[:metric (:id metric-card)]]})
:visualization_settings {}}
user)]
(testing "raw deps are recorded correctly for question using metric"
(is (=? #{(depends-on-> :card (:id question-card) :table (mt/id :orders))
(depends-on-> :card (:id question-card) :card (:id metric-card))}
(upstream-of :card (:id question-card)))))))))))
(deftest dependency-analysis-version-test
(testing "dependency_analysis_version is updated when entities are created or updated"
(testing "cards"
(mt/with-temp [:model/Card card {:dependency_analysis_version 0}]
(is (zero? (t2/select-one-fn :dependency_analysis_version :model/Card (:id card))))
(mt/with-premium-features #{:dependencies}
(card/update-card! {:card-before-update card
:card-updates {:dataset_query (mt/mbql-query orders)}}))
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Card (:id card))))))
(testing "transforms create"
(mt/with-premium-features #{:dependencies}
(mt/with-temp [:model/Transform transform]
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Transform (:id transform)))))))
(testing "transforms update"
(mt/with-premium-features #{}
(mt/with-temp [:model/Transform transform]
(is (zero? (t2/select-one-fn :dependency_analysis_version :model/Transform (:id transform))))
(mt/with-premium-features #{:dependencies}
(t2/update! :model/Transform (:id transform) {:source {:type "query" :query (mt/mbql-query products)}}))
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Transform (:id transform)))))))
(testing "snippets create"
(mt/with-premium-features #{:dependencies}
(mt/with-temp [:model/NativeQuerySnippet snippet]
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/NativeQuerySnippet (:id snippet)))))))
(testing "snippets update"
(mt/with-premium-features #{}
(mt/with-temp [:model/NativeQuerySnippet snippet]
(is (zero? (t2/select-one-fn :dependency_analysis_version :model/NativeQuerySnippet (:id snippet))))
(mt/with-premium-features #{:dependencies}
(t2/update! :model/NativeQuerySnippet (:id snippet) {:content "new content"}))
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/NativeQuerySnippet (:id snippet)))))))))
(deftest dependency-analysis-version-create-card-test
(testing "dependency_analysis_version is updated when a card is created"
(mt/with-premium-features #{:dependencies}
(mt/with-model-cleanup [:model/Card]
(mt/with-temp [:model/User user]
(let [card (card/create-card! (basic-orders) user)]
(is (= deps.graph/current-dependency-analysis-version
(t2/select-one-fn :dependency_analysis_version :model/Card :id (:id card))))))))))

View File

@@ -1,241 +0,0 @@
(ns metabase-enterprise.dependencies.native-validation-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.dependencies.native-validation :as deps.native-validation]
[metabase-enterprise.dependencies.test-util :as deps.tu]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.test-metadata :as meta]))
(defn- fake-query
([mp query]
(fake-query mp query {}))
([mp query template-tags]
{:database (:id (lib.metadata/database mp))
:type :native
:native {:query query
:template-tags template-tags}}))
(defn- validates?
[mp driver card-id expected]
(is (=? expected
(->> (lib.metadata/card mp card-id)
:dataset-query
(deps.native-validation/validate-native-query driver mp)))))
(deftest basic-deps-test
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(is (= #{{:table (meta/id :products)}}
(->> (lib.metadata/card mp 4)
:dataset-query
(deps.native-validation/native-query-deps driver mp))))
(is (= #{{:table (meta/id :products)}
{:card 1}}
(->> (lib.metadata/card mp 5)
:dataset-query
(deps.native-validation/native-query-deps driver mp))))
(is (= #{{:table (meta/id :products)}
{:card 1}
{:snippet 1}}
(->> (lib.metadata/card mp 6)
:dataset-query
(deps.native-validation/native-query-deps driver mp))))
(is (= #{{:table (meta/id :products)}
{:card 1}
{:snippet 1}
{:snippet 2}}
(->> (lib.metadata/card mp 7)
:dataset-query
(deps.native-validation/native-query-deps driver mp))))
(is (= #{{:table (meta/id :products)}
{:table (meta/id :orders)}
{:card 1}
{:card 2}}
(->> (lib.metadata/card mp 9)
:dataset-query
(deps.native-validation/native-query-deps driver mp))))))
(deftest validate-bad-queries-test
(testing "validate-native-query handles nonsense queries"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "complete nonsense query"
(is (= [{:error :bad-sql}]
(deps.native-validation/validate-native-query
driver mp
(fake-query mp "this is not a query")))))
(testing "bad table wildcard"
(is (= [{:type :invalid-table-wildcard,
:table "products",
:metabase.driver.sql/bad-reference true}]
(deps.native-validation/validate-native-query
driver mp
(fake-query mp "select products.* from orders")))))
(testing "bad col reference"
(is (= [{:column "BAD",
:alias nil,
:type :single-column,
:source-columns [[{:type :all-columns, :table {:table "PRODUCTS"}}]],
:metabase.driver.sql/bad-reference true}]
(deps.native-validation/validate-native-query
driver mp
(fake-query mp "select bad from products"))))))))
(deftest validate-native-query-with-subquery-columns-test
(testing "validate-native-query should detect invalid columns in subqueries"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "Valid query - selecting existing columns from subquery"
(validates? mp driver 10 empty?))
(testing "Invalid query - selecting non-existent column from subquery"
(validates? mp driver 11 [{:column "CATEGORY",
:alias nil,
:type :single-column,
:source-columns [[]],
:metabase.driver.sql/bad-reference true}])
(validates? mp driver 12 [{:column "CATEGORY",
:alias nil,
:type :single-column,
:source-columns [[]],
:metabase.driver.sql/bad-reference true}]))
(testing "Nested subqueries"
(validates? mp driver 13 empty?)
(validates? mp driver 14 [{:column "CATEGORY",
:alias nil,
:type :single-column,
:source-columns [[]],
:metabase.driver.sql/bad-reference true}]))
(testing "SELECT * from subquery expands to subquery columns"
(validates? mp driver 15 empty?)
(validates? mp driver 16 empty?)
(validates? mp driver 17 [{:column "EMAIL",
:alias nil,
:type :single-column,
:source-columns [[]],
:metabase.driver.sql/bad-reference true}])))))
(deftest validate-card-reference-after-expansion-test
(testing "Validation of queries after card references have been expanded"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "Card reference expanded to subquery - valid columns"
(validates? mp driver 18 empty?))
(testing "Card reference expanded to subquery - invalid column"
(validates? mp driver 19
[{:column "DESCRIPTION",
:alias nil,
:type :single-column,
:source-columns [[]],
:metabase.driver.sql/bad-reference true}]))
(testing "Card reference with alias - valid column"
(validates? mp driver 20 empty?))
(testing "Card reference with alias - invalid column"
(validates? mp driver 21
[{:column "PASSWORD",
:alias nil,
:type :single-column,
:source-columns [[]],
:metabase.driver.sql/bad-reference true}]))
(testing "Wildcard selection from card reference"
(validates? mp driver 22 empty?))
(testing "Invalid column from aliased card"
(validates? mp driver 23
[{:column "LATITUDE",
:alias nil,
:type :single-column,
:source-columns [[]],
:metabase.driver.sql/bad-reference true}])))))
(defn- check-result-metadata [driver mp query expected]
(is (=? expected
(->> query
(fake-query mp)
(deps.native-validation/native-result-metadata driver mp)))))
(defn- add-desired-column-alias [fields]
(map #(assoc % :lib/desired-column-alias (:name %)) fields))
(deftest result-metadata-test
(testing "Calculates result metadata"
(let [mp (deps.tu/default-metadata-provider)
driver (:engine (lib.metadata/database mp))]
(testing "Selecting a wildcard"
(check-result-metadata
driver mp
"select * from orders"
(add-desired-column-alias (lib.metadata/fields mp (meta/id :orders)))))
(testing "Selecting a table wildcard"
(check-result-metadata
driver mp
"select orders.* from orders"
(add-desired-column-alias (lib.metadata/fields mp (meta/id :orders)))))
(testing "Selecting a single col"
(check-result-metadata
driver mp
"select total from orders"
(add-desired-column-alias [(lib.metadata/field mp (meta/id :orders :total))])))
(testing "Selecting a nonexistent col"
(check-result-metadata
driver mp
"select bad from orders"
[]))
(testing "Selecting a col with an alias"
(check-result-metadata
driver mp
"select subtotal as new_subtotal from orders"
[{:lib/type :metadata/column,
:base-type :type/Float,
:semantic-type nil,
:name "SUBTOTAL",
:lib/desired-column-alias "NEW_SUBTOTAL"
:database-type "DOUBLE PRECISION",
:display-name "Subtotal"}]))
(testing "Selecting a custom col"
(check-result-metadata
driver mp
"select subtotal + tax as sum from orders"
[{:base-type :type/*,
:name "SUM",
:display-name "Sum",
:effective-type :type/*,
:semantic-type :Semantic/*}]))
(testing "Selecting a union"
(check-result-metadata
driver mp
"select total from orders union select subtotal from orders"
[{:name "TOTAL",
:lib/desired-column-alias "TOTAL",
:display-name "Total",
:base-type :type/Float,
:effective-type :type/Float,
:semantic-type :Semantic/*}]))
(testing "Selecting a union with different types"
(check-result-metadata
driver mp
"select category from products union select title from products"
[{:name "CATEGORY",
:lib/desired-column-alias "CATEGORY",
:display-name "Category",
:base-type :type/Text,
:effective-type :type/Text,
:semantic-type :type/Category}]))
(testing "Selecting a bad table wildcard"
(check-result-metadata
driver mp
"select orders.* from products"
[]))
(testing "Using a nonsense query"
(check-result-metadata
driver mp
"this is not a query"
[])))))

View File

@@ -1,231 +0,0 @@
(ns metabase-enterprise.dependencies.task.backfill-test
(:require
[clojure.test :refer :all]
[environ.core :as env]
[java-time.api :as t]
[metabase-enterprise.dependencies.models.dependency :as dependencies.model]
[metabase-enterprise.dependencies.task.backfill :as dependencies.backfill]
[metabase.task.core :as task]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(use-fixtures :once (fixtures/initialize :db))
(defn- backfill-all-existing-entities
[]
(mt/with-premium-features #{:dependencies}
(while (#'dependencies.backfill/backfill-dependencies))))
(defn- backfill-dependencies-single-trigger
[]
(mt/with-premium-features #{:dependencies}
(#'dependencies.backfill/backfill-dependencies)))
(deftest ^:sequential backfill-dependency-analysis-test
(testing "Test that the backfill job correctly updates the dependency_analysis_version"
(backfill-all-existing-entities)
(with-redefs [env/env (assoc env/env :mb-dependency-backfill-batch-size "2")]
(let [query (mt/mbql-query orders)]
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {card1-id :id} {:dependency_analysis_version 0, :dataset_query query}
:model/Card {card2-id :id} {:dependency_analysis_version 0, :dataset_query query}
:model/Card {card3-id :id} {:dependency_analysis_version 0, :dataset_query query}]
(let [card-count (fn [& args]
(apply t2/count :model/Card :id [:in [card1-id card2-id card3-id]] args))
current-version dependencies.model/current-dependency-analysis-version]
(is (= 3 (card-count)))
;; first run, should process 2 cards
(is (true? (backfill-dependencies-single-trigger)))
(is (= 2 (card-count :dependency_analysis_version current-version)))
;; second run, should process the last card
(is (false? (backfill-dependencies-single-trigger)))
(is (= 3 (card-count :dependency_analysis_version current-version)))
;; third run, should not process anything
(is (false? (backfill-dependencies-single-trigger)))
(is (= 3 (card-count :dependency_analysis_version current-version))))))))))
(deftest ^:sequential backfill-transform-test
(testing "Test that transforms are correctly backfilled"
(backfill-all-existing-entities)
(with-redefs [env/env (assoc env/env :mb-dependency-backfill-batch-size "2")]
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {card-id :id} {:dataset_query (mt/mbql-query orders)
:dependency_analysis_version 0}
:model/Transform {transform-id :id} {:name "Test Transform"
:source {:type "query"
:query (mt/mbql-query nil
{:source-table (str "card__" card-id)})}
:target {:type "table" :name "test_table"}
:dependency_analysis_version 0}]
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version 0))
(is (t2/exists? :model/Transform :id transform-id :dependency_analysis_version 0))
(is (false? (t2/exists? :model/Dependency :from_entity_type :card :from_entity_id card-id)))
(is (false? (t2/exists? :model/Dependency :from_entity_type :transform :from_entity_id transform-id)))
(backfill-dependencies-single-trigger)
(is (t2/exists? :model/Card :id card-id
:dependency_analysis_version dependencies.model/current-dependency-analysis-version))
(is (t2/exists? :model/Transform :id transform-id
:dependency_analysis_version dependencies.model/current-dependency-analysis-version))
(is (t2/exists? :model/Dependency
:from_entity_type :card :from_entity_id card-id
:to_entity_type :table :to_entity_id (mt/id :orders)))
(is (t2/exists? :model/Dependency
:from_entity_type :transform :from_entity_id transform-id
:to_entity_type :card :to_entity_id card-id)))))))
(deftest ^:sequential backfill-snippet-test
(testing "Test that cards with snippets are correctly backfilled"
(backfill-all-existing-entities)
(with-redefs [env/env (assoc env/env :mb-dependency-backfill-batch-size "2")]
(mt/with-premium-features #{}
(mt/with-temp [:model/NativeQuerySnippet {snippet-id :id} {:name "my_snippet"
:content "SELECT 1"
:dependency_analysis_version 0}
:model/Card {card-id :id} {:dataset_query {:database (mt/id)
:type :native
:native {:query "SELECT * FROM {{my_snippet}}"
:template-tags
{:my_snippet
{:id (str (random-uuid))
:name "my_snippet"
:display-name "my_snippet"
:type :snippet
:snippet-name "my_snippet"
:snippet-id snippet-id}}}}
:dependency_analysis_version 0}]
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version 0))
(is (t2/exists? :model/NativeQuerySnippet :id snippet-id :dependency_analysis_version 0))
(is (false? (t2/exists? :model/Dependency
:from_entity_type :card :from_entity_id card-id
:to_entity_type :snippet :to_entity_id snippet-id)))
(backfill-dependencies-single-trigger)
(is (t2/exists? :model/Card :id card-id
:dependency_analysis_version dependencies.model/current-dependency-analysis-version))
(is (t2/exists? :model/NativeQuerySnippet :id snippet-id
:dependency_analysis_version dependencies.model/current-dependency-analysis-version))
(is (t2/exists? :model/Dependency
:from_entity_type :card :from_entity_id card-id
:to_entity_type :snippet :to_entity_id snippet-id)))))))
(deftest ^:sequential backfill-idempotency-test
(testing "Running the backfill multiple times should be idempotent"
(backfill-all-existing-entities)
(with-redefs [env/env (assoc env/env :mb-dependency-backfill-batch-size "1")]
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {card-id :id} {:dataset_query (mt/mbql-query orders)
:dependency_analysis_version 0}]
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version 0))
(is (false? (t2/exists? :model/Dependency :from_entity_type :card :from_entity_id card-id
:to_entity_type :table :to_entity_id (mt/id :orders))))
;; First run
(backfill-dependencies-single-trigger)
(is (t2/exists? :model/Card :id card-id
:dependency_analysis_version dependencies.model/current-dependency-analysis-version))
;; Second run - should not change anything
(backfill-dependencies-single-trigger)
(is (t2/exists? :model/Card :id card-id
:dependency_analysis_version dependencies.model/current-dependency-analysis-version))
(is (t2/exists? :model/Dependency :from_entity_type :card :from_entity_id card-id
:to_entity_type :table :to_entity_id (mt/id :orders))))))))
(defn- wait-for-condition
[predicate timeout-ms]
(let [limit (+ (System/currentTimeMillis) timeout-ms)]
(loop []
(Thread/sleep 100)
(or (predicate)
(when (< (System/currentTimeMillis) limit)
(recur))))))
(deftest ^:sequential backfill-scheduling-test
(testing "Test that the backfill job schedules and reschedules itself correctly"
(backfill-all-existing-entities)
(mt/with-temp-scheduler!
(with-redefs [env/env (assoc env/env
:mb-dependency-backfill-batch-size "1"
:mb-dependency-backfill-delay-minutes "0"
:mb-dependency-backfill-variance-minutes "0")]
(let [card-data {:dependency_analysis_version 0, :dataset_query (mt/mbql-query orders)}]
(mt/test-helpers-set-global-values!
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {card1-id :id} card-data
:model/Card {card2-id :id} card-data
:model/Card {card3-id :id} card-data]
(let [card-count (fn [& args]
(apply t2/count :model/Card :id [:in [card1-id card2-id card3-id]] args))
current-version dependencies.model/current-dependency-analysis-version]
(is (= 3 (card-count :dependency_analysis_version 0)))
(mt/with-premium-features #{:dependencies}
;; Initialize the task, which should schedule the first run
(task/init! ::dependencies.backfill/DependencyBackfill)
(wait-for-condition #(= 3 (card-count :dependency_analysis_version current-version)) 10000)
(is (= 3 (card-count :dependency_analysis_version current-version)))))))))))))
(deftest ^:sequential backfill-terminal-failure-test
(testing "Entities should be marked as terminally broken after MAX_RETRIES failures"
(backfill-all-existing-entities)
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {card-id :id} {:dependency_analysis_version 0, :dataset_query (mt/mbql-query orders)}]
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version 0))
(let [update-attempts (volatile! 0)
failures (inc @#'dependencies.backfill/MAX_RETRIES)]
(with-redefs [env/env (assoc env/env
:mb-dependency-backfill-delay-minutes "0"
:mb-dependency-backfill-variance-minutes "0")
t2/update! (fn [model-kw id & args]
(if (and (= model-kw :model/Card)
(= id card-id)
(< @update-attempts failures))
(do
(vswap! update-attempts inc)
(throw (ex-info "Simulated DB error" {:id id})))
(apply t2/update! model-kw id args)))]
;; fail MAX_RETRIES + 1 times
(while (< @update-attempts failures)
(backfill-dependencies-single-trigger))))
;; verify card is not processed and is terminally broken
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version 0))
;; verify subsequent runs don't process it
(backfill-dependencies-single-trigger)
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version 0))))))
(deftest ^:sequential backfill-delayed-retry-test
(testing "Failed entities should be retried after their delay period expires"
(backfill-all-existing-entities)
(let [current-version dependencies.model/current-dependency-analysis-version
update-attempts (volatile! 0)]
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {card-id :id} {:dependency_analysis_version 0, :dataset_query (mt/mbql-query orders)}]
(with-redefs [env/env (assoc env/env
:mb-dependency-backfill-delay-minutes "1") ; 1 minute delay for retries
t2/update! (fn [model-kw id & args]
(if (and (= model-kw :model/Card)
(= id card-id)
(zero? @update-attempts))
(do
(vswap! update-attempts inc)
(throw (ex-info "Simulated DB error" {:id id})))
(apply t2/update! model-kw id args)))]
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version 0))
;; first failure - should be put into retry state
;; fail MAX_RETRIES + 1 times
(while (zero? @update-attempts)
(backfill-dependencies-single-trigger))
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version 0)))
;; advance time by less than retry delay - should NOT be processed
(mt/with-clock (t/plus (t/zoned-date-time) (t/duration 10 :seconds))
(backfill-dependencies-single-trigger))
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version 0))
;; advance time by more than retry delay - should be processed
(mt/with-clock (t/plus (t/zoned-date-time) (t/duration 2 :minutes))
(backfill-dependencies-single-trigger))
(is (t2/exists? :model/Card :id card-id :dependency_analysis_version current-version)))))))

View File

@@ -1,125 +0,0 @@
(ns metabase-enterprise.dependencies.test-util
(:require
[medley.core :as m]
[metabase.lib.convert :as lib.convert]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util.metadata-providers.mock :as providers.mock]))
(defn mock-card [metadata-provider {:keys [id query details]}]
(merge {:lib/type :metadata/card
:id id
:database-id (:id (lib.metadata/database metadata-provider))
:dataset-query (if (string? query)
{:database (:id (lib.metadata/database metadata-provider))
:type :native
:native {:query query
:template-tags (lib/extract-template-tags metadata-provider query)}}
(lib.convert/->legacy-MBQL query))
:name (str (gensym))
:result-metadata (when (not (string? query))
(->> (lib/returned-columns query)
(sort-by :id)))}
details))
(defn mock-snippet [{:keys [id name content template-tags]}]
{:description nil,
:archived false,
:collection-id nil,
:id id
:name name
:content content
:template-tags template-tags
:lib/type :metadata/native-query-snippet})
(defn mock-metadata-provider [{:keys [cards snippets]}]
(lib/composed-metadata-provider
meta/metadata-provider
(providers.mock/mock-metadata-provider
{:cards cards
:native-query-snippets snippets})))
(defn default-metadata-provider []
(let [snippets (map mock-snippet
[{:id 1
:name "inner"
:content "{{#card-ref}}"
:template-tags {"#card-ref" {:card-id 1
:type :card
:name "#card-ref"
:display-name "#Card Ref"
:id (str (random-uuid))}}}
{:id 2
:name "outer"
:content "{{snippet: inner}}"
:template-tags {"snippet: inner" {:snippet-id 1
:snippet-name "inner"
:type :snippet
:name "snippet: inner"
:display-name "Snippet: Inner"
:id (str (random-uuid))}}}])
initial-mock-mp (mock-metadata-provider {:snippets snippets})
initial-card-a (mock-card initial-mock-mp
{:id 1
:query (lib/query initial-mock-mp (meta/table-metadata :products))})
initial-card-b (mock-card initial-mock-mp
{:id 2
:query (lib/query initial-mock-mp (meta/table-metadata :orders))})]
(mock-metadata-provider
{:cards (into [initial-card-a
initial-card-b]
(map #(mock-card initial-mock-mp %))
[{:id 3
:query (lib/query initial-mock-mp initial-card-a)}
{:id 4
:query "select * from products"}
{:id 5
:query "select * from {{#1}}"}
{:id 6
:query "select * from {{snippet: inner}}"}
{:id 7
:query "select * from {{snippet: outer}}"}
{:id 8
:query (as-> (lib/query initial-mock-mp initial-card-b) q
(lib/join q (-> (lib/join-clause initial-card-a)
(lib/with-join-conditions
[(lib/=
(m/find-first #(= (:name %) "PRODUCT_ID")
(lib/join-condition-lhs-columns
q initial-card-b nil nil))
(m/find-first #(= (:name %) "ID")
(lib/join-condition-lhs-columns
q initial-card-a nil nil)))])
(lib/with-join-fields :all))))}
{:id 9
:query "select * from {{#1}} inner join {{#2}}"}
{:id 10
:query "SELECT id, name FROM (SELECT id, name, email FROM PEOPLE)"}
{:id 11
:query "SELECT category FROM (SELECT id, name FROM PEOPLE)"}
{:id 12
:query "SELECT id, category FROM (SELECT id, name FROM PEOPLE)"}
{:id 13
:query "SELECT id FROM (SELECT id, name FROM (SELECT id, name, email FROM PEOPLE))"}
{:id 14
:query "SELECT category FROM (SELECT id, name FROM (SELECT id, name, email FROM PEOPLE))"}
{:id 15
:query "SELECT * FROM (SELECT id, name FROM PEOPLE)"}
{:id 16
:query "SELECT name FROM (SELECT * FROM (SELECT id, name FROM PEOPLE))"}
{:id 17
:query "SELECT email FROM (SELECT * FROM (SELECT id, name FROM PEOPLE))"}
{:id 18
:query "SELECT ID, TITLE FROM {{#1}}"}
{:id 19
:query "SELECT DESCRIPTION FROM {{#1}}"}
{:id 20
:query "SELECT ID FROM {{#1}} AS p"}
{:id 21
:query "SELECT PASSWORD FROM {{#1}} AS p"}
{:id 22
:query "SELECT * FROM {{#1}}"}
{:id 23
:query "SELECT p.LATITUDE FROM {{#1}} AS p"}])
:snippets snippets})))

View File

@@ -6,8 +6,6 @@
[medley.core :as m]
[metabase-enterprise.metabot-v3.client-test :as client-test]
[metabase-enterprise.metabot-v3.util :as metabot.u]
[metabase.premium-features.token-check :as token-check]
[metabase.premium-features.token-check-test :as token-check-test]
[metabase.test :as mt]
[metabase.util.json :as json]
[toucan2.core :as t2]))
@@ -60,46 +58,3 @@
:role :assistant
:data [{:role "assistant" :content "Hello from streaming!"}]}]
messages))))))))))
(deftest feedback-endpoint-test
(mt/with-premium-features #{:metabot-v3}
(with-redefs [token-check/fetch-token-status (fn [_x]
{:valid true
:status "fake"
:features ["test" "fixture"]
:trial false})]
(let [premium-token (token-check-test/random-token)
store-url "http://hm.example"]
(testing "Submits feedback to Harbormaster with token and base URL"
(mt/with-temporary-setting-values [premium-embedding-token premium-token
store-api-url store-url]
(let [captured (atom nil)
feedback {:metabot_id 1
:feedback {:positive true
:message_id "m-1"
:freeform_feedback "ok"}
:conversation_data {}
:version "v0.0.0"
:submission_time "2025-01-01T00:00:00Z"
:is_admin false}
expected-url (str store-url "/api/v2/metabot/feedback/" premium-token)]
(mt/with-dynamic-fn-redefs
[http/post (fn [url opts]
(reset! captured {:url url
:body (json/decode+kw (:body opts))}))]
(let [_resp (mt/user-http-request :rasta :post 204 "ee/metabot-v3/feedback" feedback)]
(is (= {:url expected-url :body feedback}
@captured)))))))
(testing "Returns 500 when http post fails"
(mt/with-temporary-setting-values [premium-embedding-token premium-token]
(mt/with-dynamic-fn-redefs
[http/post (fn [_url _opts]
(throw (ex-info "boom" {})))]
(mt/user-http-request :rasta :post 500 "ee/metabot-v3/feedback" {:any "payload"}))))
;; We're not testing the branch where the store-api-url is missing because that defsetting
;; has the default value. It doesn't work well with `with-temporary-setting-values` helper.
(testing "Throws when premium token is missing"
(mt/with-temporary-setting-values [premium-embedding-token nil]
(mt/user-http-request :rasta :post 500 "ee/metabot-v3/feedback" {:foo "bar"})))))))

View File

@@ -47,7 +47,6 @@
:model/DashboardBookmark
:model/DataPermissions
:model/DatabaseRouter
:model/Dependency
:model/DocumentBookmark
:model/CollectionPermissionGraphRevision
:model/DashboardCardSeries

View File

@@ -224,14 +224,8 @@
:model/Transform child (-> (->transform "transform2" (mt/mbql-query nil {:source-table table}))
(assoc-in [:target :name] "orders_3"))]
(mt/with-premium-features #{:transforms}
(is (=? [{:name "transform1"
:source {:type "query", :query {:database (mt/id)
:type "query"
:query {:source-table (mt/id :orders)}}}
:id (:id parent)
:entity_id (:entity_id parent)
:target {:schema "public", :name "orders_2", :type "table"}}]
(mt/user-http-request :crowberto :get 200 (format "ee/transform/%s/dependencies" (:id child)))))))))
(is (= [parent]
(mt/user-http-request :crowberto :get 200 (format "ee/transform/%s/dependencies" (:id child)))))))))
(deftest put-transforms-test
(mt/test-drivers (mt/normal-drivers-with-feature :transforms/table)

View File

@@ -44,7 +44,6 @@ export const SdkSaveQuestionForm = ({ onCancel }: SaveQuestionFormProps) => {
originalQuestion={originalQuestion ?? null}
onCreate={onCreate}
onSave={onSave}
onCancel={onCancel}
multiStep={false}
targetCollection={targetCollection ?? undefined}
>

View File

@@ -1,49 +0,0 @@
import type {
CheckCardDependenciesRequest,
CheckDependenciesResponse,
CheckSnippetDependenciesRequest,
CheckTransformDependenciesRequest,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
export const dependencyApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
checkCardDependencies: builder.query<
CheckDependenciesResponse,
CheckCardDependenciesRequest
>({
query: (body) => ({
method: "POST",
url: "/api/ee/dependencies/check_card",
body,
}),
}),
checkSnippetDependencies: builder.query<
CheckDependenciesResponse,
CheckSnippetDependenciesRequest
>({
query: (body) => ({
method: "POST",
url: "/api/ee/dependencies/check_snippet",
body,
}),
}),
checkTransformDependencies: builder.query<
CheckDependenciesResponse,
CheckTransformDependenciesRequest
>({
query: (body) => ({
method: "POST",
url: "/api/ee/dependencies/check_transform",
body,
}),
}),
}),
});
export const {
useLazyCheckCardDependenciesQuery,
useLazyCheckSnippetDependenciesQuery,
useLazyCheckTransformDependenciesQuery,
} = dependencyApi;

View File

@@ -5,7 +5,6 @@ export * from "./billing-info";
export * from "./comment";
export * from "./content-translation";
export * from "./db-routing";
export * from "./dependencies";
export * from "./document";
export * from "./gdrive";
export * from "./metabot";

View File

@@ -1,6 +1,5 @@
import type {
DeleteSuggestedMetabotPromptRequest,
MetabotFeedback,
MetabotGenerateContentRequest,
MetabotGenerateContentResponse,
MetabotId,
@@ -88,13 +87,6 @@ export const metabotApi = EnterpriseApi.injectEndpoints({
body: params,
}),
}),
submitMetabotFeedback: builder.mutation<void, MetabotFeedback>({
query: (params) => ({
method: "POST",
url: "/api/ee/metabot-v3/feedback",
body: params,
}),
}),
}),
});
@@ -105,5 +97,4 @@ export const {
useDeleteSuggestedMetabotPromptMutation,
useRegenerateSuggestedMetabotPromptsMutation,
useLazyMetabotGenerateContentQuery,
useSubmitMetabotFeedbackMutation,
} = metabotApi;

View File

@@ -1,12 +0,0 @@
.card {
flex-shrink: 0;
&:hover {
background-color: var(--mb-color-background-light);
}
}
.list {
overflow: auto;
max-height: 60vh;
}

View File

@@ -1,239 +0,0 @@
import { Fragment } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import {
Form,
FormErrorMessage,
FormProvider,
FormSubmitButton,
} from "metabase/forms";
import * as Urls from "metabase/lib/urls";
import type { CheckDependenciesFormProps } from "metabase/plugins";
import {
Anchor,
Box,
Button,
Card,
FixedSizeIcon,
Group,
type IconName,
Stack,
Text,
} from "metabase/ui";
import visualizations from "metabase/visualizations";
import { getTransformUrl } from "metabase-enterprise/transforms/urls";
import type {
Card as ApiCard,
CheckDependenciesResponse,
Transform,
} from "metabase-types/api";
import S from "./CheckDependenciesForm.module.css";
type DependencyItem = CardDependencyItem | TransformDependencyItem;
type CardDependencyItem = {
type: "card";
card: ApiCard;
};
type TransformDependencyItem = {
type: "transform";
transform: Transform;
};
export function CheckDependenciesForm({
checkData,
onSave,
onCancel,
}: CheckDependenciesFormProps) {
const items = getDependencyItems(checkData);
return (
<FormProvider initialValues={{}} onSubmit={onSave}>
<Form>
<Text mb="md" px="xl">
{t`The items below will break because of these changes:`}
</Text>
<Stack className={S.list} mb="xl" px="xl">
{items.map((item, index) => (
<DependencyItemCard key={index} item={item} />
))}
</Stack>
<Group px="xl">
<Box flex={1}>
<FormErrorMessage />
</Box>
<Button onClick={onCancel}>{t`Cancel`}</Button>
<FormSubmitButton label={t`Save anyway`} variant="filled" />
</Group>
</Form>
</FormProvider>
);
}
function getDependencyItems({
bad_cards = [],
bad_transforms = [],
}: CheckDependenciesResponse): DependencyItem[] {
return [
...bad_transforms.map(
(transform): TransformDependencyItem => ({
type: "transform",
transform,
}),
),
...bad_cards.map(
(card): CardDependencyItem => ({
type: "card",
card,
}),
),
];
}
type DependencyItemCardProps = {
item: DependencyItem;
};
function DependencyItemCard({ item }: DependencyItemCardProps) {
return (
<Card
className={S.card}
component={Link}
to={getItemLink(item)}
p="md"
shadow="none"
withBorder
>
<Stack gap="xs">
<Group c="brand" gap="sm" wrap="nowrap">
<FixedSizeIcon name={getItemIcon(item)} />
<Box fw="bold" lh="h4">
{getItemName(item)}
</Box>
</Group>
{getItemDescription(item)}
</Stack>
</Card>
);
}
function getItemIcon(item: DependencyItem): IconName {
if (item.type === "card") {
const { card } = item;
switch (card.type) {
case "question":
return visualizations.get(card.display)?.iconName ?? "table2";
case "model":
return "model";
case "metric":
return "metric";
}
}
if (item.type === "transform") {
return "refresh_downstream";
}
return "unknown";
}
function getItemName(item: DependencyItem) {
switch (item.type) {
case "card":
return item.card.name;
case "transform":
return item.transform.name;
default:
return null;
}
}
function getItemLink(item: DependencyItem) {
switch (item.type) {
case "card":
return Urls.question(item.card);
default:
return getTransformUrl(item.transform.id);
}
}
function getItemDescription(item: DependencyItem) {
if (item.type === "card") {
const { collection, dashboard } = item.card;
if (collection != null) {
const ancestors = collection.effective_ancestors ?? [];
const breadcrumbs = [
...ancestors.map((ancestor) => ({
title: ancestor.name,
to: Urls.collection(ancestor),
})),
{ title: collection.name, to: Urls.collection(collection) },
...(dashboard != null
? [{ title: dashboard.name, to: Urls.dashboard(dashboard) }]
: []),
];
return (
<Group gap="sm" wrap="nowrap">
<FixedSizeIcon
c="text-secondary"
name={dashboard != null ? "dashboard" : "collection"}
flex="0 0 auto"
/>
<BreadcrumbList items={breadcrumbs} />
</Group>
);
}
}
if (item.type === "transform") {
return (
<Box fz="sm" lh="h5">
{t`Transform`}
</Box>
);
}
return null;
}
type BreadcrumbItem = {
title: string;
to: string;
};
type BreadcrumbProps = {
item: BreadcrumbItem;
};
function Breadcrumb({ item }: BreadcrumbProps) {
return (
<Anchor component={Link} to={item.to} c="text-secondary" fz="sm" lh="h5">
{item.title}
</Anchor>
);
}
type BreadcrumbListProps = {
items: BreadcrumbItem[];
};
function BreadcrumbList({ items }: BreadcrumbListProps) {
return (
<Group align="center" gap="xs">
{items.map((item, itemIndex) => (
<Fragment key={itemIndex}>
{itemIndex > 0 && (
<FixedSizeIcon name="chevronright" c="text-secondary" size={8} />
)}
<Breadcrumb item={item} />
</Fragment>
))}
</Group>
);
}

View File

@@ -1,194 +0,0 @@
import { Route } from "react-router";
import { getIcon, renderWithProviders, screen } from "__support__/ui";
import type { IconName } from "metabase/ui";
import registerVisualizations from "metabase/visualizations/register";
import type {
CardDisplayType,
CardType,
CheckDependenciesResponse,
} from "metabase-types/api";
import {
createMockCard,
createMockCheckDependenciesResponse,
createMockCollection,
createMockTransform,
} from "metabase-types/api/mocks";
import { CheckDependenciesForm } from "./CheckDependenciesForm";
type SetupOpts = {
checkData?: CheckDependenciesResponse;
};
function setup({
checkData = createMockCheckDependenciesResponse(),
}: SetupOpts) {
const onSave = jest.fn();
const onCancel = jest.fn();
renderWithProviders(
<Route
path="/"
component={() => (
<CheckDependenciesForm
checkData={checkData}
onSave={onSave}
onCancel={onCancel}
/>
)}
/>,
{ withRouter: true, initialRoute: "/" },
);
return { onSave, onCancel };
}
registerVisualizations();
describe("CheckDependenciesForm", () => {
describe.each<CardType>(["question", "model", "metric"])("%s", (type) => {
it("should display broken cards", () => {
setup({
checkData: createMockCheckDependenciesResponse({
success: false,
bad_cards: [
createMockCard({
name: "Card",
type,
}),
],
}),
});
expect(screen.getByText("Card")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Card/ })).toHaveAttribute(
"href",
`/${type}/1-card`,
);
});
it("should display the root collection", () => {
setup({
checkData: createMockCheckDependenciesResponse({
success: false,
bad_cards: [
createMockCard({
name: "Card",
type,
collection: createMockCollection({
id: "root",
name: "Our analytics",
}),
}),
],
}),
});
expect(
screen.getByRole("link", { name: "Our analytics" }),
).toHaveAttribute("href", "/collection/root");
});
it("should display the nested collection with parent collections", () => {
setup({
checkData: createMockCheckDependenciesResponse({
success: false,
bad_cards: [
createMockCard({
name: "Card",
type,
collection: createMockCollection({
id: 2,
name: "Second collection",
effective_ancestors: [
createMockCollection({ id: 1, name: "First collection" }),
createMockCollection({ id: "root", name: "Our analytics" }),
],
}),
}),
],
}),
});
expect(
screen.getByRole("link", { name: "Our analytics" }),
).toHaveAttribute("href", "/collection/root");
expect(
screen.getByRole("link", { name: "First collection" }),
).toHaveAttribute("href", "/collection/1-first-collection");
expect(
screen.getByRole("link", { name: "Second collection" }),
).toHaveAttribute("href", "/collection/2-second-collection");
});
});
it.each<{ display: CardDisplayType; icon: IconName }>([
{ display: "line", icon: "line" },
{ display: "pie", icon: "pie" },
])(
"should display the icon that corresponds to the display type",
({ display, icon }) => {
setup({
checkData: createMockCheckDependenciesResponse({
success: false,
bad_cards: [
createMockCard({
type: "question",
display,
}),
],
}),
});
expect(getIcon(icon)).toBeInTheDocument();
},
);
it("should display the dashboard that the question belongs to", () => {
setup({
checkData: createMockCheckDependenciesResponse({
success: false,
bad_cards: [
createMockCard({
name: "Card",
type: "question",
collection: createMockCollection({
id: 1,
name: "First collection",
effective_ancestors: [
createMockCollection({ id: "root", name: "Our analytics" }),
],
}),
dashboard: {
id: 2,
name: "Card dashboard",
},
}),
],
}),
});
expect(screen.getByRole("link", { name: "Our analytics" })).toHaveAttribute(
"href",
"/collection/root",
);
expect(
screen.getByRole("link", { name: "First collection" }),
).toHaveAttribute("href", "/collection/1-first-collection");
expect(
screen.getByRole("link", { name: "Card dashboard" }),
).toHaveAttribute("href", "/dashboard/2-card-dashboard");
});
it("should display transforms", () => {
setup({
checkData: createMockCheckDependenciesResponse({
success: false,
bad_transforms: [
createMockTransform({
id: 1,
name: "SQL transform",
}),
],
}),
});
expect(screen.getByRole("link", { name: /SQL transform/ })).toHaveAttribute(
"href",
"/admin/transforms/1",
);
});
});

View File

@@ -1 +0,0 @@
export * from "./CheckDependenciesForm";

View File

@@ -1,35 +0,0 @@
import type { CheckDependenciesModalProps } from "metabase/plugins";
import { Flex, Modal } from "metabase/ui";
import { CheckDependenciesForm } from "../CheckDependenciesForm";
import { CheckDependenciesTitle } from "../CheckDependenciesTitle";
export function CheckDependenciesModal({
checkData,
opened,
onSave,
onClose,
}: CheckDependenciesModalProps) {
return (
<Modal.Root size="xl" padding="xl" opened={opened} onClose={onClose}>
<Modal.Overlay />
<Modal.Content>
<Modal.Header px="xl">
<Modal.Title>
<CheckDependenciesTitle />
</Modal.Title>
<Flex justify="flex-end">
<Modal.CloseButton />
</Flex>
</Modal.Header>
<Modal.Body px={0}>
<CheckDependenciesForm
checkData={checkData}
onSave={onSave}
onCancel={onClose}
/>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -1 +0,0 @@
export * from "./CheckDependenciesModal";

View File

@@ -1,11 +0,0 @@
import { t } from "ttag";
import { Box } from "metabase/ui";
export function CheckDependenciesTitle() {
return (
<Box fz="h3" lh="h3">
{t`These changes will break some other things. Save anyway?`}
</Box>
);
}

View File

@@ -1 +0,0 @@
export * from "./CheckDependenciesTitle";

View File

@@ -1 +0,0 @@
export * from "./use-check-card-dependencies";

View File

@@ -1,40 +0,0 @@
import { useCallback } from "react";
import { useStore } from "metabase/lib/redux";
import type {
UseCheckDependenciesProps,
UseCheckDependenciesResult,
} from "metabase/plugins";
import { getSubmittableQuestion } from "metabase/query_builder/selectors";
import { useLazyCheckCardDependenciesQuery } from "metabase-enterprise/api";
import type Question from "metabase-lib/v1/Question";
import type { CheckCardDependenciesRequest } from "metabase-types/api";
import { useCheckDependencies } from "../use-check-dependencies";
export function useCheckCardDependencies({
onSave,
onError,
}: UseCheckDependenciesProps<Question>): UseCheckDependenciesResult<Question> {
const store = useStore();
const getCheckDependenciesRequest = useCallback(
(question: Question): CheckCardDependenciesRequest => {
const submittableQuestion = getSubmittableQuestion(
store.getState(),
question,
);
const { id, type, dataset_query, result_metadata } =
submittableQuestion.card();
return { id, type, dataset_query, result_metadata };
},
[store],
);
return useCheckDependencies({
getCheckDependenciesRequest,
useLazyCheckDependenciesQuery: useLazyCheckCardDependenciesQuery,
onSave,
onError,
});
}

View File

@@ -1,77 +0,0 @@
import { setupCheckCardDependenciesEndpoint } from "__support__/server-mocks/dependencies";
import { act, renderHookWithProviders } from "__support__/ui";
import { SAMPLE_METADATA } from "metabase-lib/test-helpers";
import Question from "metabase-lib/v1/Question";
import type { CheckDependenciesResponse } from "metabase-types/api";
import { createMockCheckDependenciesResponse } from "metabase-types/api/mocks";
import { useCheckCardDependencies } from "./use-check-card-dependencies";
type SetupOpts = {
response?: CheckDependenciesResponse;
};
function setup({
response = createMockCheckDependenciesResponse(),
}: SetupOpts = {}) {
setupCheckCardDependenciesEndpoint(response);
const onSave = jest.fn();
const onError = jest.fn();
const { result } = renderHookWithProviders(
() => useCheckCardDependencies({ onSave, onError }),
{},
);
return { result, onSave, onError };
}
describe("useCheckCardDependencies", () => {
it("should save if there are no broken dependencies", async () => {
const question = Question.create({ metadata: SAMPLE_METADATA });
const response = createMockCheckDependenciesResponse({ success: true });
const { result, onSave } = setup({ response });
await act(() => result.current.handleInitialSave(question));
expect(onSave).toHaveBeenCalledWith(question);
});
it("should not save if there are broken dependencies", async () => {
const question = Question.create({ metadata: SAMPLE_METADATA });
const response = createMockCheckDependenciesResponse({ success: false });
const { result, onSave } = setup({ response });
await act(() => result.current.handleInitialSave(question));
expect(result.current.checkData).toEqual(response);
expect(result.current.isConfirmationShown).toBe(true);
expect(onSave).not.toHaveBeenCalled();
});
it("should allow to confirm saving", async () => {
const question = Question.create({ metadata: SAMPLE_METADATA });
const response = createMockCheckDependenciesResponse({ success: false });
const { result, onSave } = setup({ response });
await act(() => result.current.handleInitialSave(question));
await act(() => result.current.handleSaveAfterConfirmation());
expect(result.current.checkData).toEqual(response);
expect(result.current.isConfirmationShown).toBe(false);
expect(onSave).toHaveBeenCalledWith(question);
});
it("should allow to cancel the confirmation screen", async () => {
const question = Question.create({ metadata: SAMPLE_METADATA });
const response = createMockCheckDependenciesResponse({ success: false });
const { result, onSave } = setup({ response });
await act(() => result.current.handleInitialSave(question));
await act(() => result.current.handleCloseConfirmation());
expect(result.current.checkData).toEqual(response);
expect(result.current.isConfirmationShown).toBe(false);
expect(onSave).not.toHaveBeenCalled();
});
});

View File

@@ -1 +0,0 @@
export * from "./use-check-dependencies";

View File

@@ -1,80 +0,0 @@
import { useCallback, useState } from "react";
import type { UseCheckDependenciesResult } from "metabase/plugins";
import type { CheckDependenciesResponse } from "metabase-types/api";
type UseCheckDependenciesQueryData = {
data?: CheckDependenciesResponse;
error?: unknown;
isLoading: boolean;
};
type UseCheckDependenciesArgsData<TRequest> = {
lastArg?: TRequest;
};
type UseCheckDependenciesQueryResult<TRequest> = [
(request: TRequest) => Promise<UseCheckDependenciesQueryData>,
UseCheckDependenciesQueryData,
UseCheckDependenciesArgsData<TRequest>,
];
type UseCheckDependenciesProps<TChange, TRequest> = {
getCheckDependenciesRequest: (change: TChange) => TRequest;
useLazyCheckDependenciesQuery: () => UseCheckDependenciesQueryResult<TRequest>;
onSave: (change: TChange) => Promise<void>;
onError: (error: unknown) => void;
};
export function useCheckDependencies<TChange, TRequest>({
getCheckDependenciesRequest,
useLazyCheckDependenciesQuery,
onSave,
onError,
}: UseCheckDependenciesProps<
TChange,
TRequest
>): UseCheckDependenciesResult<TChange> {
const [change, setChange] = useState<TChange | null>(null);
const [isConfirmationShown, setIsConfirmationShown] = useState(false);
const [checkDependencies, { data, isLoading }] =
useLazyCheckDependenciesQuery();
const handleInitialSave = useCallback(
async (change: TChange) => {
const { data, error } = await checkDependencies(
getCheckDependenciesRequest(change),
);
if (error != null) {
onError(error);
} else if (data != null && !data.success) {
setChange(change);
setIsConfirmationShown(true);
} else {
await onSave(change);
}
},
[getCheckDependenciesRequest, checkDependencies, onSave, onError],
);
const handleCloseConfirmation = useCallback(() => {
setChange(null);
setIsConfirmationShown(false);
}, []);
const handleSaveAfterConfirmation = useCallback(async () => {
if (change != null) {
await onSave(change);
handleCloseConfirmation();
}
}, [change, onSave, handleCloseConfirmation]);
return {
checkData: data,
isCheckingDependencies: isLoading,
isConfirmationShown,
handleInitialSave,
handleSaveAfterConfirmation,
handleCloseConfirmation,
};
}

View File

@@ -1 +0,0 @@
export * from "./use-check-snippet-dependencies";

View File

@@ -1,31 +0,0 @@
import type {
UseCheckDependenciesProps,
UseCheckDependenciesResult,
} from "metabase/plugins";
import { useLazyCheckSnippetDependenciesQuery } from "metabase-enterprise/api";
import type {
CheckSnippetDependenciesRequest,
UpdateSnippetRequest,
} from "metabase-types/api";
import { useCheckDependencies } from "../use-check-dependencies";
export function useCheckSnippetDependencies({
onSave,
onError,
}: UseCheckDependenciesProps<UpdateSnippetRequest>): UseCheckDependenciesResult<UpdateSnippetRequest> {
return useCheckDependencies({
getCheckDependenciesRequest,
useLazyCheckDependenciesQuery: useLazyCheckSnippetDependenciesQuery,
onSave,
onError,
});
}
function getCheckDependenciesRequest({
id,
name,
content,
}: UpdateSnippetRequest): CheckSnippetDependenciesRequest {
return { id, name, content };
}

View File

@@ -1,52 +0,0 @@
import { setupCheckSnippetDependenciesEndpoint } from "__support__/server-mocks/dependencies";
import { act, renderHookWithProviders } from "__support__/ui";
import type { CheckDependenciesResponse } from "metabase-types/api";
import {
createMockCheckDependenciesResponse,
createMockUpdateSnippetRequest,
} from "metabase-types/api/mocks";
import { useCheckSnippetDependencies } from "./use-check-snippet-dependencies";
type SetupOpts = {
response?: CheckDependenciesResponse;
};
function setup({
response = createMockCheckDependenciesResponse(),
}: SetupOpts = {}) {
setupCheckSnippetDependenciesEndpoint(response);
const onSave = jest.fn();
const onError = jest.fn();
const { result } = renderHookWithProviders(
() => useCheckSnippetDependencies({ onSave, onError }),
{},
);
return { result, onSave, onError };
}
describe("useCheckSnippetDependencies", () => {
it("should save if there are no broken dependencies", async () => {
const request = createMockUpdateSnippetRequest();
const response = createMockCheckDependenciesResponse({ success: true });
const { result, onSave } = setup({ response });
await act(() => result.current.handleInitialSave(request));
expect(onSave).toHaveBeenCalledWith(request);
});
it("should not save if there are broken dependencies", async () => {
const request = createMockUpdateSnippetRequest();
const response = createMockCheckDependenciesResponse({ success: false });
const { result, onSave } = setup({ response });
await act(() => result.current.handleInitialSave(request));
expect(result.current.checkData).toEqual(response);
expect(result.current.isConfirmationShown).toBe(true);
expect(onSave).not.toHaveBeenCalled();
});
});

View File

@@ -1 +0,0 @@
export * from "./use-check-transform-dependencies";

View File

@@ -1,30 +0,0 @@
import type {
UseCheckDependenciesProps,
UseCheckDependenciesResult,
} from "metabase/plugins";
import { useLazyCheckTransformDependenciesQuery } from "metabase-enterprise/api";
import type {
CheckTransformDependenciesRequest,
UpdateTransformRequest,
} from "metabase-types/api";
import { useCheckDependencies } from "../use-check-dependencies";
export function useCheckTransformDependencies({
onSave,
onError,
}: UseCheckDependenciesProps<UpdateTransformRequest>): UseCheckDependenciesResult<UpdateTransformRequest> {
return useCheckDependencies({
getCheckDependenciesRequest,
useLazyCheckDependenciesQuery: useLazyCheckTransformDependenciesQuery,
onSave,
onError,
});
}
function getCheckDependenciesRequest({
id,
source,
}: UpdateTransformRequest): CheckTransformDependenciesRequest {
return { id, source };
}

View File

@@ -1,52 +0,0 @@
import { setupCheckTransformDependenciesEndpoint } from "__support__/server-mocks/dependencies";
import { act, renderHookWithProviders } from "__support__/ui";
import type { CheckDependenciesResponse } from "metabase-types/api";
import {
createMockCheckDependenciesResponse,
createMockUpdateTransformRequest,
} from "metabase-types/api/mocks";
import { useCheckTransformDependencies } from "./use-check-transform-dependencies";
type SetupOpts = {
response?: CheckDependenciesResponse;
};
function setup({
response = createMockCheckDependenciesResponse(),
}: SetupOpts = {}) {
setupCheckTransformDependenciesEndpoint(response);
const onSave = jest.fn();
const onError = jest.fn();
const { result } = renderHookWithProviders(
() => useCheckTransformDependencies({ onSave, onError }),
{},
);
return { result, onSave, onError };
}
describe("useCheckTransformDependencies", () => {
it("should save if there are no broken dependencies", async () => {
const request = createMockUpdateTransformRequest();
const response = createMockCheckDependenciesResponse({ success: true });
const { result, onSave } = setup({ response });
await act(() => result.current.handleInitialSave(request));
expect(onSave).toHaveBeenCalledWith(request);
});
it("should not save if there are broken dependencies", async () => {
const request = createMockUpdateTransformRequest();
const response = createMockCheckDependenciesResponse({ success: false });
const { result, onSave } = setup({ response });
await act(() => result.current.handleInitialSave(request));
expect(result.current.checkData).toEqual(response);
expect(result.current.isConfirmationShown).toBe(true);
expect(onSave).not.toHaveBeenCalled();
});
});

View File

@@ -1,19 +0,0 @@
import { PLUGIN_DEPENDENCIES } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { CheckDependenciesForm } from "./components/CheckDependenciesForm";
import { CheckDependenciesModal } from "./components/CheckDependenciesModal";
import { CheckDependenciesTitle } from "./components/CheckDependenciesTitle";
import { useCheckCardDependencies } from "./hooks/use-check-card-dependencies";
import { useCheckSnippetDependencies } from "./hooks/use-check-snippet-dependencies";
import { useCheckTransformDependencies } from "./hooks/use-check-transform-dependencies";
if (hasPremiumFeature("dependencies")) {
PLUGIN_DEPENDENCIES.CheckDependenciesForm = CheckDependenciesForm;
PLUGIN_DEPENDENCIES.CheckDependenciesModal = CheckDependenciesModal;
PLUGIN_DEPENDENCIES.CheckDependenciesTitle = CheckDependenciesTitle;
PLUGIN_DEPENDENCIES.useCheckCardDependencies = useCheckCardDependencies;
PLUGIN_DEPENDENCIES.useCheckSnippetDependencies = useCheckSnippetDependencies;
PLUGIN_DEPENDENCIES.useCheckTransformDependencies =
useCheckTransformDependencies;
}

View File

@@ -22,7 +22,6 @@ export const ALLOWED_EMBED_SETTING_KEYS_MAP = {
"theme",
"locale",
"preferredAuthMethod",
"fetchRequestToken",
"useExistingUserSession",
] satisfies (keyof SdkIframeEmbedBaseSettings)[],
dashboard: [
@@ -79,5 +78,4 @@ export type AllowedEmbedSettingKey =
export const DISABLE_UPDATE_FOR_KEYS = [
"instanceUrl",
"useExistingUserSession",
"fetchRequestToken",
] as const satisfies AllowedEmbedSettingKey[];

View File

@@ -412,24 +412,7 @@ export abstract class MetabaseEmbedElement extends HTMLElement {
data: Message["data"],
) {
if (this._iframe?.contentWindow) {
const normalizedData = Object.entries(data).reduce(
(acc, [key, value]) => {
// Functions are not serializable, so we ignore them.
if (typeof value === "function") {
return acc;
}
acc[key as keyof typeof acc] = value;
return acc;
},
{} as Message["data"],
);
this._iframe.contentWindow.postMessage(
{ type, data: normalizedData },
"*",
);
this._iframe.contentWindow.postMessage({ type, data }, "*");
}
}
@@ -463,8 +446,7 @@ export abstract class MetabaseEmbedElement extends HTMLElement {
* @returns {{ method: "saml" | "jwt", sessionToken: {jwt: string} }}
*/
private async _getMetabaseSessionToken() {
const { instanceUrl, preferredAuthMethod, fetchRequestToken } =
this.properties;
const { instanceUrl, preferredAuthMethod } = this.properties;
const urlResponseJson = await connectToInstanceAuthSso(instanceUrl, {
headers: this._getAuthRequestHeader(),
@@ -484,7 +466,6 @@ export abstract class MetabaseEmbedElement extends HTMLElement {
responseUrl,
instanceUrl,
this._getAuthRequestHeader(hash),
fetchRequestToken,
);
return { method, sessionToken };

View File

@@ -1,10 +1,7 @@
import type { MetabaseError } from "embedding-sdk-bundle/errors";
import type { MetabaseErrorCode } from "embedding-sdk-bundle/errors/error-code";
import type { MetabaseAuthMethod } from "embedding-sdk-bundle/types";
import type {
MetabaseEmbeddingSessionToken,
MetabaseFetchRequestTokenFn,
} from "embedding-sdk-bundle/types/refresh-token";
import type { MetabaseEmbeddingSessionToken } from "embedding-sdk-bundle/types/refresh-token";
import type {
CollectionBrowserListColumns,
EmbeddingEntityType,
@@ -143,7 +140,6 @@ export type SdkIframeEmbedBaseSettings = {
theme?: MetabaseTheme;
locale?: string;
preferredAuthMethod?: MetabaseAuthMethod;
fetchRequestToken?: MetabaseFetchRequestTokenFn;
/** Whether we should use the existing user session (i.e. admin user's cookie) */
useExistingUserSession?: boolean;
@@ -159,10 +155,7 @@ export type SdkIframeEmbedTemplateSettings =
| BrowserEmbedOptions;
/** Settings used by the sdk embed route */
export type SdkIframeEmbedSettings = Omit<
SdkIframeEmbedBaseSettings,
"fetchRequestToken"
> &
export type SdkIframeEmbedSettings = SdkIframeEmbedBaseSettings &
SdkIframeEmbedTemplateSettings;
export type SdkIframeEmbedElementSettings = SdkIframeEmbedBaseSettings &

View File

@@ -1,9 +1,9 @@
import { useClipboard } from "@mantine/hooks";
import cx from "classnames";
import { useCallback, useState } from "react";
import { t } from "ttag";
import { useToast } from "metabase/common/hooks";
import { downloadObjectAsJson } from "metabase/lib/download";
import {
ActionIcon,
Flex,
@@ -12,7 +12,6 @@ import {
type IconName,
Text,
} from "metabase/ui";
import { useSubmitMetabotFeedbackMutation } from "metabase-enterprise/api/metabot";
import type {
MetabotChatMessage,
MetabotErrorMessage,
@@ -261,25 +260,19 @@ export const Messages = ({
modal: undefined,
});
const [submitMetabotFeedback] = useSubmitMetabotFeedbackMutation();
const submitFeedback = async (metabotFeedback: MetabotFeedback) => {
const { message_id, positive } = metabotFeedback.feedback;
try {
await submitMetabotFeedback(metabotFeedback).unwrap();
sendToast({ icon: "check", message: t`Feedback submitted` });
downloadObjectAsJson(metabotFeedback, `metabot-feedback-${message_id}`);
sendToast({ icon: "check", message: "Feedback downloaded successfully" });
setFeedbackState((prevState) => ({
submitted: {
...prevState.submitted,
[message_id]: positive ? "positive" : "negative",
},
modal: undefined,
}));
} catch (error) {
sendToast({ icon: "warning", message: t`Failed to submit feedback` });
}
setFeedbackState((prevState) => ({
submitted: {
...prevState.submitted,
[message_id]: positive ? "positive" : "negative",
},
modal: undefined,
}));
};
const onAgentMessageCopy = useCallback(

View File

@@ -126,7 +126,7 @@ export const MetabotFeedbackModal = ({
</Button>
<Button variant="filled" type="submit">{c(
"This is a verb, not a noun",
).t`Submit`}</Button>
).t`Download`}</Button>
</Group>
</Stack>
</Form>

View File

@@ -16,6 +16,7 @@ import {
} from "__support__/ui";
import { logout } from "metabase/auth/actions";
import * as domModule from "metabase/lib/dom";
import { downloadObjectAsJson } from "metabase/lib/download";
import { useRegisterMetabotContextProvider } from "metabase/metabot";
import {
type MockStreamedEndpointParams,
@@ -51,6 +52,10 @@ import {
setVisible,
} from "./state";
jest.mock("metabase/lib/download", () => ({
downloadObjectAsJson: jest.fn(),
}));
const mockAgentEndpoint = (params: MockStreamedEndpointParams) =>
mockStreamedEndpoint("/api/ee/metabot-v3/agent-streaming", params);
@@ -277,10 +282,7 @@ describe("metabot-streaming", () => {
});
it("should present the user an option to provide feedback", async () => {
const feedbackPath = "path:/api/ee/metabot-v3/feedback";
setup();
fetchMock.post(feedbackPath, 204);
mockAgentEndpoint({ textChunks: whoIsYourFavoriteResponse });
await enterChatMessage("Who is your favorite?");
@@ -292,6 +294,10 @@ describe("metabot-streaming", () => {
within(lastMessage!).findByTestId("metabot-chat-message-thumbs-up");
const thumbsDown = () =>
within(lastMessage!).findByTestId("metabot-chat-message-thumbs-down");
const mockDownloadObjectAsJson =
downloadObjectAsJson as jest.MockedFunction<
typeof downloadObjectAsJson
>;
expect(await thumbsUp()).toBeInTheDocument();
expect(await thumbsDown()).toBeInTheDocument();
@@ -300,11 +306,11 @@ describe("metabot-streaming", () => {
expect(await feedbackModal()).toBeInTheDocument();
await userEvent.click(
await within(await feedbackModal()).findByRole("button", {
name: /Submit/,
name: /Download/,
}),
);
expect(fetchMock.callHistory.calls(feedbackPath)).toHaveLength(1);
expect(mockDownloadObjectAsJson).toHaveBeenCalledTimes(1);
expect(await thumbsUp()).toBeDisabled();
expect(await thumbsDown()).toBeDisabled();

View File

@@ -39,7 +39,6 @@ import "./clean_up";
import "./metabot";
import "./database_replication";
import "./table-editing";
import "./dependencies";
import "./documents";
import "./semantic_search";
import "./transforms";

View File

@@ -6,10 +6,7 @@ import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErr
import { useDispatch } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { useMetadataToasts } from "metabase/metadata/hooks";
import {
PLUGIN_DEPENDENCIES,
PLUGIN_TRANSFORMS_PYTHON,
} from "metabase/plugins";
import { PLUGIN_TRANSFORMS_PYTHON } from "metabase/plugins";
import {
useGetTransformQuery,
useUpdateTransformMutation,
@@ -53,38 +50,22 @@ type TransformQueryPageBodyProps = {
export function TransformQueryPageBody({
transform,
}: TransformQueryPageBodyProps) {
const [updateTransform, { isLoading: isSaving }] =
useUpdateTransformMutation();
const [updateTransform, { isLoading }] = useUpdateTransformMutation();
const dispatch = useDispatch();
const { sendSuccessToast, sendErrorToast } = useMetadataToasts();
const { sendErrorToast, sendSuccessToast } = useMetadataToasts();
const {
checkData,
isCheckingDependencies,
isConfirmationShown,
handleInitialSave,
handleSaveAfterConfirmation,
handleCloseConfirmation,
} = PLUGIN_DEPENDENCIES.useCheckTransformDependencies({
onSave: async (request) => {
const { error } = await updateTransform(request);
if (error) {
sendErrorToast(t`Failed to update transform query`);
} else {
sendSuccessToast(t`Transform query updated`);
dispatch(push(getTransformUrl(transform.id)));
}
},
onError: () => {
sendErrorToast(t`Failed to update transform query`);
},
});
const handleSaveSource = async (source: TransformSource) => {
await handleInitialSave({
const handleSourceSave = async (source: TransformSource) => {
const { error } = await updateTransform({
id: transform.id,
source,
});
if (error) {
sendErrorToast(t`Failed to update transform query`);
} else {
sendSuccessToast(t`Transform query updated`);
dispatch(push(getTransformUrl(transform.id)));
}
};
const handleCancel = () => {
@@ -96,29 +77,19 @@ export function TransformQueryPageBody({
<PLUGIN_TRANSFORMS_PYTHON.TransformEditor
initialSource={transform.source}
isNew={false}
isSaving={isSaving}
onSave={handleSaveSource}
isSaving={isLoading}
onSave={handleSourceSave}
onCancel={handleCancel}
/>
);
}
return (
<>
<QueryEditor
initialSource={transform.source}
isNew={false}
isSaving={isSaving || isCheckingDependencies}
onSave={handleSaveSource}
onCancel={handleCancel}
/>
{isConfirmationShown && checkData != null && (
<PLUGIN_DEPENDENCIES.CheckDependenciesModal
checkData={checkData}
opened
onSave={handleSaveAfterConfirmation}
onClose={handleCloseConfirmation}
/>
)}
</>
<QueryEditor
initialSource={transform.source}
isNew={false}
isSaving={isLoading}
onSave={handleSourceSave}
onCancel={handleCancel}
/>
);
}

View File

@@ -1,19 +0,0 @@
import type { NativeQuerySnippet } from "metabase-types/api/snippets";
import type { Card } from "./card";
import type { Transform } from "./transform";
export type CheckDependenciesResponse = {
success: boolean;
bad_cards?: Card[];
bad_transforms?: Transform[];
};
export type CheckCardDependenciesRequest = Pick<Card, "id"> &
Partial<Pick<Card, "type" | "dataset_query" | "result_metadata">>;
export type CheckSnippetDependenciesRequest = Pick<NativeQuerySnippet, "id"> &
Partial<Pick<NativeQuerySnippet, "name" | "content">>;
export type CheckTransformDependenciesRequest = Pick<Transform, "id"> &
Partial<Pick<Transform, "source">>;

View File

@@ -16,7 +16,6 @@ export * from "./content-translation";
export * from "./dashboard";
export * from "./database";
export * from "./dataset";
export * from "./dependencies";
export * from "./email";
export * from "./entity-id";
export * from "./field";

View File

@@ -1,42 +0,0 @@
import type {
CheckCardDependenciesRequest,
CheckDependenciesResponse,
CheckSnippetDependenciesRequest,
CheckTransformDependenciesRequest,
} from "metabase-types/api";
export function createMockCheckCardDependenciesRequest(
opts?: Partial<CheckCardDependenciesRequest>,
): CheckCardDependenciesRequest {
return {
id: 1,
...opts,
};
}
export function createMockCheckSnippetDependenciesRequest(
opts?: Partial<CheckSnippetDependenciesRequest>,
): CheckSnippetDependenciesRequest {
return {
id: 1,
...opts,
};
}
export function createMockCheckTransformDependenciesRequest(
opts?: Partial<CheckTransformDependenciesRequest>,
): CheckTransformDependenciesRequest {
return {
id: 1,
...opts,
};
}
export function createMockCheckDependenciesResponse(
opts?: Partial<CheckDependenciesResponse>,
): CheckDependenciesResponse {
return {
success: true,
...opts,
};
}

View File

@@ -9,7 +9,6 @@ export * from "./collection";
export * from "./dashboard";
export * from "./database";
export * from "./dataset";
export * from "./dependencies";
export * from "./document";
export * from "./field";
export * from "./geojson";

View File

@@ -101,7 +101,6 @@ export const createMockTokenFeatures = (
advanced_permissions: false,
audit_app: false,
cache_granular_controls: false,
dependencies: false,
disable_password_login: false,
cloud_custom_smtp: false,
content_translation: false,

View File

@@ -1,7 +1,4 @@
import type {
NativeQuerySnippet,
UpdateSnippetRequest,
} from "metabase-types/api";
import type { NativeQuerySnippet } from "metabase-types/api";
import { createMockEntityId } from "./entity-id";
import { createMockUser } from "./user";
@@ -25,12 +22,3 @@ export const createMockNativeQuerySnippet = ({
updated_at: new Date().toISOString(),
...opts,
});
export function createMockUpdateSnippetRequest(
opts?: Partial<UpdateSnippetRequest>,
): UpdateSnippetRequest {
return {
id: 1,
...opts,
};
}

View File

@@ -7,7 +7,6 @@ import type {
TransformSource,
TransformTag,
TransformTarget,
UpdateTransformRequest,
} from "metabase-types/api";
import { createMockStructuredDatasetQuery } from "./query";
@@ -101,12 +100,3 @@ export function createMockTransformJob(
...opts,
};
}
export function createMockUpdateTransformRequest(
opts?: Partial<UpdateTransformRequest>,
): UpdateTransformRequest {
return {
id: 1,
...opts,
};
}

View File

@@ -321,7 +321,6 @@ export const tokenFeatures = [
"etl_connections",
"etl_connections_pg",
"table_data_editing",
"dependencies",
"documents",
"semantic_search",
"transforms",
@@ -684,6 +683,7 @@ export interface EnterpriseSettings extends Settings {
"python-storage-s-3-endpoint"?: string | null;
"python-storage-s-3-region"?: string | null;
"python-storage-s-3-bucket"?: string | null;
"python-storage-s-3-prefix"?: string | null;
"python-storage-s-3-access-key"?: string | null;
"python-storage-s-3-secret-key"?: string | null;
"python-storage-s-3-container-endpoint"?: string | null;

View File

@@ -25,17 +25,17 @@ export interface ListSnippetsParams {
}
export interface CreateSnippetRequest {
name: string;
content: string;
description?: string | null;
name: string;
description?: string;
collection_id?: RegularCollectionId | null;
}
export interface UpdateSnippetRequest {
id: NativeQuerySnippetId;
name?: string;
description?: string | null;
content?: string;
collection_id?: RegularCollectionId | null;
archived?: boolean;
content?: string | null;
name?: string | null;
description?: string | null;
collection_id?: RegularCollectionId | null;
}

View File

@@ -15,7 +15,6 @@ export type SaveQuestionProps<C = CollectionId> = {
},
) => Promise<Question>;
onSave: (question: Question) => Promise<void>;
onCancel?: () => void;
closeOnSuccess?: boolean;
multiStep?: boolean;

View File

@@ -5,7 +5,6 @@ import {
import { SaveQuestionProvider } from "metabase/common/components/SaveQuestionForm/context";
import type { SaveQuestionProps } from "metabase/common/components/SaveQuestionForm/types";
import { useEscapeToCloseModal } from "metabase/common/hooks/use-escape-to-close-modal";
import { PLUGIN_DEPENDENCIES } from "metabase/plugins";
import { Flex, Modal, type ModalProps } from "metabase/ui";
type SaveQuestionModalProps = Omit<SaveQuestionProps, "initialDashboardTabId"> &
@@ -15,7 +14,6 @@ export const SaveQuestionModal = ({
multiStep,
onCreate,
onSave,
onClose,
originalQuestion,
question,
closeOnSuccess,
@@ -23,19 +21,7 @@ export const SaveQuestionModal = ({
targetCollection,
...modalProps
}: SaveQuestionModalProps) => {
const {
checkData,
isConfirmationShown,
handleInitialSave,
handleSaveAfterConfirmation,
} = PLUGIN_DEPENDENCIES.useCheckCardDependencies({
onSave,
onError: (error) => {
throw error;
},
});
useEscapeToCloseModal(onClose);
useEscapeToCloseModal(modalProps.onClose);
return (
<SaveQuestionProvider
question={question}
@@ -44,51 +30,32 @@ export const SaveQuestionModal = ({
const newQuestion = await onCreate(question, options);
if (closeOnSuccess) {
onClose();
modalProps.onClose();
}
return newQuestion;
}}
onSave={handleInitialSave}
onCancel={onClose}
onSave={onSave}
multiStep={multiStep}
initialCollectionId={initialCollectionId}
targetCollection={targetCollection}
>
<Modal.Root
padding="xl"
{...modalProps}
size={isConfirmationShown ? "xl" : undefined}
closeOnEscape={false}
onClose={onClose}
>
<Modal.Root padding="2.5rem" {...modalProps} closeOnEscape={false}>
<Modal.Overlay />
<Modal.Content data-testid="save-question-modal">
<Modal.Header px={isConfirmationShown ? "xl" : undefined}>
<Modal.Header>
<Modal.Title>
{isConfirmationShown ? (
<PLUGIN_DEPENDENCIES.CheckDependenciesTitle />
) : (
<SaveQuestionTitle />
)}
<SaveQuestionTitle />
</Modal.Title>
<Flex justify="flex-end">
<Flex align="center" justify="flex-end" gap="sm">
<Modal.CloseButton />
</Flex>
</Modal.Header>
<Modal.Body px={isConfirmationShown ? 0 : undefined}>
{checkData != null && isConfirmationShown ? (
<PLUGIN_DEPENDENCIES.CheckDependenciesForm
checkData={checkData}
onSave={handleSaveAfterConfirmation}
onCancel={onClose}
/>
) : (
<SaveQuestionForm
onSaveSuccess={() => closeOnSuccess && onClose()}
onCancel={onClose}
/>
)}
<Modal.Body>
<SaveQuestionForm
onSaveSuccess={() => closeOnSuccess && modalProps.onClose()}
onCancel={modalProps.onClose}
/>
</Modal.Body>
</Modal.Content>
</Modal.Root>

View File

@@ -37,11 +37,11 @@ import {
} from "../analytics";
import type { SectionLayout } from "../sections";
import {
getCurrentDashcards,
getDashCardById,
getDashboard,
getDashboardId,
getDashboards,
getDashcardList,
getDashcards,
getSelectedTabId,
} from "../selectors";
@@ -471,7 +471,7 @@ export const removeCardFromDashboard = createThunkAction(
}) =>
(dispatch, getState) => {
const dashboard = checkNotNull(getDashboard(getState()));
const dashcards = getCurrentDashcards(getState());
const dashcards = getDashcardList(getState());
const dashcard = getDashCardById(getState(), dashcardId);
const originalParameters = dashboard.parameters

View File

@@ -51,13 +51,13 @@ import {
} from "../analytics";
import {
getAutoApplyFiltersToastId,
getCurrentDashcards,
getDashCardById,
getDashboard,
getDashboardBeforeEditing,
getDashboardComplete,
getDashboardHeaderParameters,
getDashboardId,
getDashcardList,
getDashcards,
getDraftParameterValues,
getFiltersToReset,
@@ -274,7 +274,7 @@ export const setEditingParameter =
const currentTabId = getSelectedTabId(getState());
const parameterDashcard = findDashCardForInlineParameter(
parameterId,
getCurrentDashcards(getState()),
getDashcardList(getState()),
);
if (

View File

@@ -170,6 +170,10 @@ export const getDashboard = createSelector(
export const getDashcards = (state: State) => state.dashboard.dashcards;
export const getDashcardList = createSelector([getDashcards], (dashcards) =>
Object.values(dashcards),
);
export const getDashCardById = (state: State, dashcardId: DashCardId) => {
const dashcards = getDashcards(state);
return dashcards[dashcardId];
@@ -219,11 +223,6 @@ export const getDashboardComplete = createSelector(
},
);
export const getCurrentDashcards = createSelector(
[getDashboardComplete],
(dashboard) => dashboard?.dashcards || [],
);
export const getDashcardHref = createSelector(
[getMetadata, getDashboardComplete, getParameterValues, getDashCardById],
(metadata, dashboard, parameterValues, dashcard) => {
@@ -444,8 +443,9 @@ export const getParameters = createSelector(
);
export const getDashboardHeaderParameters = createSelector(
[getCurrentDashcards, getParameters],
(dashcardList, parameters) => {
[getDashcards, getParameters],
(dashcards, parameters) => {
const dashcardList = Object.values(dashcards);
return parameters.filter(
(parameter) => !isDashcardInlineParameter(parameter.id, dashcardList),
);

View File

@@ -4,7 +4,6 @@ import { createMockEntitiesState } from "__support__/store";
import {
getClickBehaviorSidebarDashcard,
getDashboardComplete,
getDashboardHeaderParameters,
getEditingParameterId,
getIsEditingParameter,
getIsSharing,
@@ -223,39 +222,6 @@ describe("dashboard/selectors", () => {
});
});
describe("getDashboardHeaderParameters", () => {
it("should ignore dashcards that don't belong to the current dashboard", () => {
const parameterId = "_parameterId_";
const state = {
...STATE,
dashboard: {
...STATE.dashboard,
dashboards: {
0: {
...STATE.dashboard.dashboards[0],
dashcards: [1],
parameters: [{ id: parameterId }],
},
},
dashcards: {
0: {
...STATE.dashboard.dashcards[0],
inline_parameters: [parameterId],
dashboard_id: 1,
},
1: {
...STATE.dashboard.dashcards[1],
dashboard_id: 0,
},
},
},
};
const headerParameters = getDashboardHeaderParameters(state);
expect(headerParameters).toHaveLength(1);
expect(headerParameters[0].id).toBe(parameterId);
});
});
describe("getSidebar", () => {
it("should return the sidebar property", () => {
expect(getSidebar(STATE)).toBe(STATE.dashboard.sidebar);

View File

@@ -96,7 +96,7 @@ export function DetailViewPage({
w={isMediumBreakpoint ? "100%" : undefined}
>
<Stack gap={rem(64)} h="100%" maw={rem(900)} w="100%">
{columns.length > 0 && (
{columns.length - headerColumns.length > 0 && (
<DetailsGroup
columns={columns}
responsive={isSmallBreakpoint}

View File

@@ -352,7 +352,7 @@ export function DetailViewSidesheet({
<Group pb={rem(48)} pt="xl" px={rem(56)}>
<Stack gap={rem(64)} h="100%" maw={rem(900)} w="100%">
{columns.length > 0 && (
{columns.length - headerColumns.length > 0 && (
<DetailsGroup
columns={columns}
columnsSettings={columnsSettings}

View File

@@ -1,23 +1,19 @@
import { memo, useMemo } from "react";
import { memo } from "react";
import { t } from "ttag";
import { useGetAdhocQueryQuery } from "metabase/api";
import { getErrorMessage } from "metabase/api/utils";
import EmptyState from "metabase/common/components/EmptyState";
import { DetailsGroup, Header } from "metabase/detail-view/components";
import { getEntityIcon, getHeaderColumns } from "metabase/detail-view/utils";
import { Box, Repeat, Skeleton, Stack, rem } from "metabase/ui";
import { extractRemappedColumns } from "metabase/visualizations";
import { Repeat, Skeleton, Stack } from "metabase/ui";
import Visualization from "metabase/visualizations/components/Visualization";
import type {
DatabaseId,
DatasetColumn,
DatasetQuery,
Field,
FieldFilter,
FieldId,
FieldReference,
RawSeries,
RowValues,
TableId,
} from "metabase-types/api";
import { createMockCard } from "metabase-types/api/mocks";
@@ -46,14 +42,7 @@ const ObjectDetailPreviewBase = ({
});
const data = rawSeries?.[0]?.data;
const columns: DatasetColumn[] = useMemo(
() => data?.cols ?? [],
[data?.cols],
);
const row: RowValues | undefined = data?.rows[0];
const headerColumns = useMemo(() => getHeaderColumns(columns), [columns]);
const icon = getEntityIcon(field.table?.entity_type);
const zoomedRow = data?.rows[0];
if (isFetching) {
return (
@@ -69,7 +58,7 @@ const ObjectDetailPreviewBase = ({
return <Error message={error} />;
}
if (!data || !row || columns.length === 0) {
if (!data || !zoomedRow) {
return (
<Stack h="100%" justify="center" p="md">
<EmptyState title={t`No data to show`} />
@@ -77,28 +66,7 @@ const ObjectDetailPreviewBase = ({
);
}
return (
<Stack gap={0} p="lg">
{headerColumns.length > 0 && (
<Box pb="md" pt="xs">
<Box ml={rem(-8)}>
<Header columns={columns} icon={icon} row={row} />
</Box>
</Box>
)}
{columns.length > 0 && (
<Box pt="xl">
<DetailsGroup
columns={columns}
row={row}
table={field.table}
responsive
/>
</Box>
)}
</Stack>
);
return <Visualization rawSeries={rawSeries} />;
};
function useDataSample({ databaseId, field, fieldId, tableId }: Props) {
@@ -155,7 +123,7 @@ function useDataSample({ databaseId, field, fieldId, tableId }: Props) {
display: "object",
visualization_settings: {},
}),
data: extractRemappedColumns(data.data),
data: data.data,
},
];

View File

@@ -5,7 +5,7 @@ import _ from "underscore";
import CS from "metabase/css/core/index.css";
import { moveParameter } from "metabase/dashboard/actions";
import { getCurrentDashcards, getTabs } from "metabase/dashboard/selectors";
import { getDashcardList, getTabs } from "metabase/dashboard/selectors";
import {
findDashCardForInlineParameter,
isHeadingDashCard,
@@ -29,13 +29,13 @@ export function MoveParameterMenu({ parameterId }: MoveParameterMenuProps) {
const ref = useRef<HTMLInputElement>(null);
const dashcards = useSelector((state) =>
getCurrentDashcards(state).filter(
getDashcardList(state).filter(
(dashcard) => isHeadingDashCard(dashcard) || isQuestionDashCard(dashcard),
),
);
const parameterDashcard = useSelector((state) => {
const dashcards = getCurrentDashcards(state);
const dashcards = getDashcardList(state);
return findDashCardForInlineParameter(parameterId, dashcards);
});

View File

@@ -50,9 +50,6 @@ async function setup({ tabs = [], dashcards }: SetupOpts) {
dashcards,
parameters: [TEST_PARAMETER],
});
const otherDashboardDashcards: DashboardCard[] = [
{ ...TEST_DASHCARD, id: 1001, dashboard_id: 1001 },
];
renderWithProviders(<MoveParameterMenu parameterId={TEST_PARAMETER.id} />, {
storeInitialState: createMockState({
@@ -64,10 +61,7 @@ async function setup({ tabs = [], dashcards }: SetupOpts) {
dashcards: dashcards.map((dc) => dc.id),
},
},
dashcards: {
..._.indexBy(dashcards, "id"),
..._.indexBy(otherDashboardDashcards, "id"),
},
dashcards: _.indexBy(dashcards, "id"),
}),
}),
});

View File

@@ -58,7 +58,6 @@ import type {
Bookmark,
CacheableDashboard,
CacheableModel,
CheckDependenciesResponse,
Collection,
CollectionAuthorityLevelConfig,
CollectionEssentials,
@@ -87,8 +86,6 @@ import type {
Timeline,
TimelineEvent,
Transform,
UpdateSnippetRequest,
UpdateTransformRequest,
User,
VisualizationDisplay,
} from "metabase-types/api";
@@ -874,66 +871,3 @@ export const PLUGIN_TRANSFORMS_PYTHON: PythonTransformsPlugin = {
getTransformsNavLinks: () => null,
getCreateTransformsMenuItems: () => null,
};
type DependenciesPlugin = {
CheckDependenciesForm: ComponentType<CheckDependenciesFormProps>;
CheckDependenciesModal: ComponentType<CheckDependenciesModalProps>;
CheckDependenciesTitle: ComponentType;
useCheckCardDependencies: (
props: UseCheckDependenciesProps<Question>,
) => UseCheckDependenciesResult<Question>;
useCheckSnippetDependencies: (
props: UseCheckDependenciesProps<UpdateSnippetRequest>,
) => UseCheckDependenciesResult<UpdateSnippetRequest>;
useCheckTransformDependencies: (
props: UseCheckDependenciesProps<UpdateTransformRequest>,
) => UseCheckDependenciesResult<UpdateTransformRequest>;
};
export type CheckDependenciesFormProps = {
checkData: CheckDependenciesResponse;
onSave: () => void | Promise<void>;
onCancel: () => void;
};
export type CheckDependenciesModalProps = {
checkData: CheckDependenciesResponse;
opened: boolean;
onSave: () => void | Promise<void>;
onClose: () => void;
};
export type UseCheckDependenciesProps<TChange> = {
onSave: (change: TChange) => Promise<void>;
onError: (error: unknown) => void;
};
export type UseCheckDependenciesResult<TChange> = {
checkData?: CheckDependenciesResponse;
isCheckingDependencies: boolean;
isConfirmationShown: boolean;
handleInitialSave: (change: TChange) => Promise<void>;
handleSaveAfterConfirmation: () => Promise<void>;
handleCloseConfirmation: () => void;
};
function useCheckDependencies<TChange>({
onSave,
}: UseCheckDependenciesProps<TChange>): UseCheckDependenciesResult<TChange> {
return {
isConfirmationShown: false,
isCheckingDependencies: false,
handleInitialSave: onSave,
handleSaveAfterConfirmation: () => Promise.resolve(),
handleCloseConfirmation: () => undefined,
};
}
export const PLUGIN_DEPENDENCIES: DependenciesPlugin = {
CheckDependenciesForm: PluginPlaceholder,
CheckDependenciesModal: PluginPlaceholder,
CheckDependenciesTitle: PluginPlaceholder,
useCheckCardDependencies: useCheckDependencies,
useCheckSnippetDependencies: useCheckDependencies,
useCheckTransformDependencies: useCheckDependencies,
};

View File

@@ -5,18 +5,14 @@ import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useMount, usePrevious } from "react-use";
import { t } from "ttag";
import { useListModelIndexesQuery } from "metabase/api";
import ActionButton, {
type ActionButtonHandle,
} from "metabase/common/components/ActionButton";
import ActionButton from "metabase/common/components/ActionButton";
import Button from "metabase/common/components/Button";
import DebouncedFrame from "metabase/common/components/DebouncedFrame";
import EditBar from "metabase/common/components/EditBar";
@@ -25,7 +21,6 @@ import ButtonsS from "metabase/css/components/buttons.module.css";
import CS from "metabase/css/core/index.css";
import { connect, useDispatch } from "metabase/lib/redux";
import { getSemanticTypeIcon } from "metabase/lib/schema_metadata";
import { PLUGIN_DEPENDENCIES } from "metabase/plugins";
import {
setDatasetEditorTab,
setUIControls,
@@ -479,28 +474,6 @@ const _DatasetEditorInner = (props: DatasetEditorInnerProps) => {
}
};
const saveButtonRef = useRef<ActionButtonHandle>(null);
const {
checkData,
isConfirmationShown,
handleInitialSave,
handleSaveAfterConfirmation,
handleCloseConfirmation,
} = PLUGIN_DEPENDENCIES.useCheckCardDependencies({
onSave: async (question) => {
await onSave(question, { rerunQuery: true });
await setQueryBuilderMode("view");
runQuestionQuery();
},
onError: (error) => {
throw error;
},
});
useLayoutEffect(() => {
saveButtonRef.current?.resetState();
}, [isConfirmationShown]);
const handleSave = useCallback(async () => {
const canBeDataset = checkCanBeModel(question);
const isBrandNewDataset = !question.id();
@@ -522,7 +495,9 @@ const _DatasetEditorInner = (props: DatasetEditorInnerProps) => {
});
onOpenModal(MODAL_TYPES.SAVE);
} else if (canBeDataset) {
await handleInitialSave(questionWithMetadata);
await onSave(questionWithMetadata, { rerunQuery: true });
await setQueryBuilderMode("view");
runQuestionQuery();
} else {
onOpenModal(MODAL_TYPES.CAN_NOT_CREATE_MODEL);
throw new Error(t`Variables in models aren't supported yet`);
@@ -535,7 +510,9 @@ const _DatasetEditorInner = (props: DatasetEditorInnerProps) => {
dispatch,
updateQuestion,
onOpenModal,
handleInitialSave,
onSave,
setQueryBuilderMode,
runQuestionQuery,
]);
const handleColumnSelect = useCallback(
@@ -693,7 +670,6 @@ const _DatasetEditorInner = (props: DatasetEditorInnerProps) => {
disabled={!saveButtonTooltipLabel}
>
<ActionButton
ref={saveButtonRef}
key="save"
disabled={!canSaveChanges}
actionFn={handleSave}
@@ -747,7 +723,7 @@ const _DatasetEditorInner = (props: DatasetEditorInnerProps) => {
noHeader
queryBuilderMode="dataset"
onHeaderColumnReorder={handleHeaderColumnReorder}
isShowingDetailsOnlyColumns={datasetEditorTab !== "metadata"}
isShowingDetailsOnlyColumns={datasetEditorTab === "columns"}
hasMetadataPopovers={false}
handleVisualizationClick={handleTableElementClick}
tableHeaderHeight={isEditingColumns && TABLE_HEADER_HEIGHT}
@@ -768,15 +744,6 @@ const _DatasetEditorInner = (props: DatasetEditorInnerProps) => {
onConfirm={handleCancelEdit}
onClose={closeModal}
/>
{isConfirmationShown && checkData != null && (
<PLUGIN_DEPENDENCIES.CheckDependenciesModal
checkData={checkData}
opened
onSave={handleSaveAfterConfirmation}
onClose={handleCloseConfirmation}
/>
)}
</>
);
};

View File

@@ -10,6 +10,7 @@ import { ResizableBox, type ResizableBoxProps } from "react-resizable";
import _ from "underscore";
import ExplicitSize from "metabase/common/components/ExplicitSize";
import Modal from "metabase/common/components/Modal";
import Databases from "metabase/entities/databases";
import SnippetCollections from "metabase/entities/snippet-collections";
import Snippets from "metabase/entities/snippets";
@@ -19,7 +20,7 @@ import {
setIsNativeEditorOpen,
setUIControls,
} from "metabase/query_builder/actions";
import { SnippetFormModal } from "metabase/query_builder/components/template_tags/SnippetFormModal";
import SnippetFormModal from "metabase/query_builder/components/template_tags/SnippetFormModal";
import type { QueryModalType } from "metabase/query_builder/constants";
import { useNotebookScreenSize } from "metabase/query_builder/hooks/use-notebook-screen-size";
import { Flex } from "metabase/ui";
@@ -371,9 +372,8 @@ class NativeQueryEditor extends Component<Props, NativeQueryEditorState> {
canSaveSnippets={canSaveSnippets}
/>
{this.props.modalSnippet &&
this.props.insertSnippet &&
this.props.closeSnippetModal && (
{this.props.modalSnippet && (
<Modal onClose={this.props.closeSnippetModal}>
<SnippetFormModal
snippet={this.props.modalSnippet}
onCreate={this.props.insertSnippet}
@@ -387,7 +387,8 @@ class NativeQueryEditor extends Component<Props, NativeQueryEditorState> {
}}
onClose={this.props.closeSnippetModal}
/>
)}
</Modal>
)}
</div>
);
}

View File

@@ -1,19 +1,26 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { t } from "ttag";
import _ from "underscore";
import * as Yup from "yup";
import FormCollectionPicker from "metabase/collections/containers/FormCollectionPicker";
import { canonicalCollectionId } from "metabase/collections/utils";
import Button from "metabase/common/components/Button";
import FormErrorMessage from "metabase/common/components/FormErrorMessage";
import FormInput from "metabase/common/components/FormInput";
import FormSubmitButton from "metabase/common/components/FormSubmitButton";
import FormTextArea from "metabase/common/components/FormTextArea";
import SnippetCollections from "metabase/entities/snippet-collections";
import Snippets from "metabase/entities/snippets";
import { Form, FormProvider } from "metabase/forms";
import * as Errors from "metabase/lib/errors";
import { connect } from "metabase/lib/redux";
import { Flex } from "metabase/ui";
import type { Collection, NativeQuerySnippet } from "metabase-types/api";
import type {
Collection,
NativeQuerySnippet,
NativeQuerySnippetId,
} from "metabase-types/api";
import S from "./SnippetForm.module.css";
@@ -30,34 +37,60 @@ const SNIPPET_SCHEMA = Yup.object({
collection_id: Yup.number().nullable().default(null),
});
export type SnippetFormValues = Pick<
type SnippetFormValues = Pick<
NativeQuerySnippet,
"name" | "description" | "content" | "collection_id"
>;
type UpdateSnippetFormValues = Partial<SnippetFormValues> &
Pick<NativeQuerySnippet, "id"> & {
archived?: boolean;
};
export interface SnippetFormOwnProps {
snippet?: Partial<SnippetFormValues>;
isEditing: boolean;
isDirty?: boolean;
onSubmit: (snippet: SnippetFormValues) => void | Promise<void>;
onArchive?: () => void | Promise<void>;
snippet: Partial<NativeQuerySnippet>;
onCreate?: (snippet: NativeQuerySnippet) => void;
onUpdate?: (
nextSnippet: NativeQuerySnippet,
originalSnippet: NativeQuerySnippet,
) => void;
onArchive?: () => void;
onCancel?: () => void;
}
interface SnippetLoaderProps {
snippetCollections: Collection[];
}
type SnippetFormProps = SnippetFormOwnProps & SnippetLoaderProps;
interface SnippetFormDispatchProps {
handleCreateSnippet: (
snippet: SnippetFormValues,
) => Promise<NativeQuerySnippet>;
handleUpdateSnippet: (
snippet: UpdateSnippetFormValues,
) => Promise<NativeQuerySnippet>;
}
type SnippetFormProps = SnippetFormOwnProps &
SnippetLoaderProps &
SnippetFormDispatchProps;
const mapDispatchToProps = {
handleCreateSnippet: Snippets.actions.create,
handleUpdateSnippet: Snippets.actions.update,
};
function SnippetForm({
snippet,
snippetCollections,
isEditing,
isDirty: isInitiallyDirty = false,
onSubmit,
handleCreateSnippet,
handleUpdateSnippet,
onCreate,
onUpdate,
onArchive,
onCancel,
}: SnippetFormProps) {
const isEditing = snippet.id != null;
const hasManyCollections = snippetCollections.length > 1;
const initialValues = useMemo(
@@ -65,26 +98,65 @@ function SnippetForm({
SNIPPET_SCHEMA.cast(
{
...snippet,
content: snippet?.content || "",
content: snippet.content || "",
parent_id: canonicalCollectionId(snippet.id),
},
{ stripUnknown: true },
),
[snippet],
);
const handleCreate = useCallback(
async (values: SnippetFormValues) => {
const action = await handleCreateSnippet(values);
const snippet = Snippets.HACK_getObjectFromAction(action);
onCreate?.(snippet);
},
[handleCreateSnippet, onCreate],
);
const handleUpdate = useCallback(
async (values: UpdateSnippetFormValues) => {
const action = await handleUpdateSnippet(values);
const nextSnippet = Snippets.HACK_getObjectFromAction(action);
onUpdate?.(nextSnippet, snippet as NativeQuerySnippet);
},
[snippet, handleUpdateSnippet, onUpdate],
);
const handleSubmit = useCallback(
async (values: SnippetFormValues) => {
if (isEditing && snippet.id) {
await handleUpdate({ ...values, id: snippet.id });
} else {
await handleCreate(values);
}
},
[snippet.id, isEditing, handleCreate, handleUpdate],
);
const handleArchive = useCallback(async () => {
await handleUpdateSnippet({
id: snippet.id as NativeQuerySnippetId,
archived: true,
});
onArchive?.();
}, [snippet.id, handleUpdateSnippet, onArchive]);
return (
<FormProvider
initialValues={initialValues}
validationSchema={SNIPPET_SCHEMA}
onSubmit={onSubmit}
onSubmit={handleSubmit}
>
{({ dirty }) => (
<Form disabled={!dirty && !isInitiallyDirty} className={S.SnippetForm}>
<Form disabled={!dirty} className={S.SnippetForm}>
<FormTextArea
inputClassName={S.FormSnippetTextArea}
name="content"
title={t`Enter some SQL here so you can reuse it later`}
placeholder="AND canceled_at IS null\nAND account_type = 'PAID'"
autoFocus
rows={4}
/>
<FormInput
@@ -112,10 +184,8 @@ function SnippetForm({
type="button"
icon="archive"
borderless
onClick={onArchive}
>
{t`Archive`}
</Button>
onClick={handleArchive}
>{t`Archive`}</Button>
)}
<FormErrorMessage inline />
</Flex>
@@ -123,11 +193,7 @@ function SnippetForm({
{!!onCancel && (
<Button type="button" onClick={onCancel}>{t`Cancel`}</Button>
)}
<FormSubmitButton
title={t`Save`}
disabled={!dirty && !isInitiallyDirty}
primary
/>
<FormSubmitButton title={t`Save`} disabled={!dirty} primary />
</Flex>
</Flex>
</Form>
@@ -137,4 +203,7 @@ function SnippetForm({
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default _.compose(SnippetCollections.loadList())(SnippetForm);
export default _.compose(
SnippetCollections.loadList(),
connect(null, mapDispatchToProps),
)(SnippetForm);

View File

@@ -1,3 +1,3 @@
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./SnippetForm";
export type { SnippetFormValues } from "./SnippetForm";
export type { SnippetFormOwnProps } from "./SnippetForm";

View File

@@ -1,138 +1,60 @@
import { useCallback, useState } from "react";
import { useCallback } from "react";
import { t } from "ttag";
import Snippets from "metabase/entities/snippets";
import { useDispatch } from "metabase/lib/redux";
import { PLUGIN_DEPENDENCIES } from "metabase/plugins";
import { Flex, Modal } from "metabase/ui";
import type {
CreateSnippetRequest,
NativeQuerySnippet,
UpdateSnippetRequest,
} from "metabase-types/api";
import ModalContent from "metabase/common/components/ModalContent";
import type { NativeQuerySnippet } from "metabase-types/api";
import SnippetForm, { type SnippetFormValues } from "../SnippetForm";
import type { SnippetFormOwnProps } from "../SnippetForm";
import SnippetForm from "../SnippetForm";
type SnippetModalProps = {
snippet: NativeQuerySnippet | Partial<Omit<NativeQuerySnippet, "id">>;
onCreate: (snippet: NativeQuerySnippet) => void;
onUpdate: (
nextSnippet: NativeQuerySnippet,
originalSnippet: NativeQuerySnippet,
) => void;
onClose: () => void;
};
interface SnippetFormModalOwnProps
extends Omit<SnippetFormOwnProps, "onCancel"> {
onClose?: () => void;
}
export function SnippetFormModal({
snippet: initialSnippet,
type SnippetModalProps = SnippetFormModalOwnProps;
function SnippetFormModal({
snippet,
onCreate,
onUpdate,
onClose,
...props
}: SnippetModalProps) {
const [snippet, setSnippet] = useState(initialSnippet);
const dispatch = useDispatch();
const isEditing = isSavedSnippet(snippet);
const modalTitle = isEditing
const isEditing = snippet.id != null;
const title = isEditing
? t`Editing ${snippet.name}`
: t`Create your new snippet`;
const handleCreate = useCallback(
async (values: CreateSnippetRequest) => {
const action = await dispatch(Snippets.actions.create(values));
const snippet = Snippets.HACK_getObjectFromAction(action);
(snippet: NativeQuerySnippet) => {
onCreate?.(snippet);
onClose?.();
},
[dispatch, onCreate, onClose],
[onCreate, onClose],
);
const handleUpdate = useCallback(
async (values: UpdateSnippetRequest) => {
if (!isSavedSnippet(snippet)) {
return;
}
const action = await dispatch(Snippets.actions.update(snippet, values));
const nextSnippet = Snippets.HACK_getObjectFromAction(action);
onUpdate?.(nextSnippet, snippet);
(nextSnippet: NativeQuerySnippet, originalSnippet: NativeQuerySnippet) => {
onUpdate?.(nextSnippet, originalSnippet);
onClose?.();
},
[snippet, dispatch, onUpdate, onClose],
);
const handleArchive = useCallback(async () => {
if (!isSavedSnippet(snippet)) {
return;
}
await dispatch(Snippets.actions.update({ id: snippet.id, archived: true }));
onClose?.();
}, [snippet, dispatch, onClose]);
const {
checkData,
isConfirmationShown,
handleInitialSave,
handleSaveAfterConfirmation,
handleCloseConfirmation,
} = PLUGIN_DEPENDENCIES.useCheckSnippetDependencies({
onSave: handleUpdate,
onError: (error) => {
throw error;
},
});
const handleSubmit = useCallback(
async (values: SnippetFormValues) => {
if (isSavedSnippet(snippet)) {
setSnippet({ ...snippet, ...values });
await handleInitialSave({ ...values, id: snippet.id });
} else {
await handleCreate(values);
}
},
[snippet, handleCreate, handleInitialSave],
[onUpdate, onClose],
);
return (
<Modal.Root padding="xl" opened onClose={onClose}>
<Modal.Overlay />
<Modal.Content>
<Modal.Header px="xl">
<Modal.Title>
{isConfirmationShown ? (
<PLUGIN_DEPENDENCIES.CheckDependenciesTitle />
) : (
modalTitle
)}
</Modal.Title>
<Flex justify="flex-end">
<Modal.CloseButton />
</Flex>
</Modal.Header>
<Modal.Body px={isConfirmationShown ? 0 : "xl"}>
{isConfirmationShown && checkData != null ? (
<PLUGIN_DEPENDENCIES.CheckDependenciesForm
checkData={checkData}
onSave={handleSaveAfterConfirmation}
onCancel={handleCloseConfirmation}
/>
) : (
<SnippetForm
snippet={snippet}
isEditing={isEditing}
isDirty={checkData != null}
onSubmit={handleSubmit}
onArchive={handleArchive}
onCancel={onClose}
/>
)}
</Modal.Body>
</Modal.Content>
</Modal.Root>
<ModalContent title={title} onClose={onClose}>
<SnippetForm
{...props}
snippet={snippet}
onCreate={handleCreate}
onUpdate={handleUpdate}
onArchive={onClose}
onCancel={onClose}
/>
</ModalContent>
);
}
function isSavedSnippet(
snippet: NativeQuerySnippet | Partial<Omit<NativeQuerySnippet, "id">>,
): snippet is NativeQuerySnippet {
return "id" in snippet;
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default SnippetFormModal;

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