Compare commits

...

5 Commits

Author SHA1 Message Date
Alex Yarosh
a126634b6d Merge branch 'master' into docs-jwt-update 2025-08-06 09:27:57 -04:00
Metabase Automation
cd7b11a0f5 Optimized 2 images, saved 0 bytes (0% smaller) 2025-08-05 20:36:45 +00:00
Alex Yarosh
55fd704dc4 Merge branch 'master' into docs-jwt-update 2025-08-05 16:34:51 -04:00
Metabase Automation
65c893a6a1 Optimized 2 images, saved 188.8 KB (46.0% smaller) 2025-08-05 18:09:57 +00:00
Alex Yarosh
07862a7bb2 jwt edits 2025-08-05 14:05:14 -04:00
44 changed files with 374 additions and 396 deletions

View File

@@ -169,7 +169,9 @@ You'll map this string in the `groups` key to a Metabase group, so that when the
In Metabase's admin section, go to **Settings** > **Authentication**. Scroll to the **JWT** card and click **Edit**.
In the **Group schema** section, toggle on **Synchronize group memberships**. For each group you want to sync, add a group mapping. When you click **New mapping**, enter "Customer-Acme", the string that you included in the `groups` array in your JWT payload. You can then associate that group name with the Metabase group "Customer Acme" that we created earlier.
In the **Group schema** section, toggle on **Synchronize group memberships**. If the names of groups in the `groups` array match Metabase group names exactly (e.g. both are `"Customer Acme"`), then the groups will be mapped automatically.
If the JWT group names and Metabase group names don't match, then for each group you want to sync, add a group mapping. When you click **New mapping**, enter "Customer-Acme", the string that you included in the `groups` array in your JWT payload. You can then associate that group name with the Metabase group "Customer Acme" that we created earlier.
![Mapping user attributes to groups.](./images/sync-groups.png)

View File

@@ -22,56 +22,73 @@ Assuming your site is localhost serving on port 3000:
5. In the event of a successful sign-in, your authentication app should issue a GET request to your Metabase endpoint with the token and the "return to" URI: `http://localhost:3000/auth/sso?jwt=TOKEN_GOES_HERE&return_to=/question/1-superb-question`.
6. Metabase verifies the JSON Web Token, logs the person in, then redirects the person to their original destination, `/question/1-superb-question`.
## Enabling JWT authentication
## Set up JWT authentication
Navigate to the **Admin**>**Settings** section of the Admin area, then click on the **Authentication** tab. Click the **Configure** button in the JWT section of this page, and you'll see this form:
Navigate to the **Admin**>**Settings** section of the Admin area, then click on the **Authentication > JWT** tab.
![JWT form](images/JWT-auth-form.png)
Here's a breakdown of each of the settings:
**JWT Identity Provider URI:** This is where Metabase will redirect login requests. That is, it's where your users go to log in through your identity provider.
- **JWT Identity Provider URI**: This is where Metabase will redirect login requests. That is, it's where your users go to log in through your identity provider.
**String Used by the JWT Signing Key:** The string used to seed the private key used to validate JWT messages. Both Metabase and the authentication app should have the same JWT signing key.
- **String Used by the JWT Signing Key**: The string used to seed the private key used to validate JWT messages. Both Metabase and the authentication app should have the same JWT signing key.
## User attribute configuration (optional)
These are additional settings you can fill in to pass user attributes to Metabase.
- **Email attribute:** the key to retrieve each JWT user's email address.
- **First Name attribute:** the key to retrieve each JWT user's first name.
- **Last Name attribute:** if you guessed that this is the key to retrieve each JWT user's last name, well then you have been paying attention.
- **First name attribute:** the key to retrieve each JWT user's first name.
- **Last name attribute:** if you guessed that this is the key to retrieve each JWT user's last name, well then you have been paying attention.
- **Group assignment attribute:** the key to retrieve each JWT user's group assignments.
You can send additional user attributes to Metabase by adding the attributes as key/value pairs to your JWT. These attributes will be synced on every login.
## Configure group mappings
You can use your JWT to assign Metabase users to custom groups.
You can use your JWT to assign Metabase users to custom Metabase [groups](./managing.md#groups) based on their attributes, e.g. automatically assign everyone with a certain JWT attribute to the `Sales` group in Metabase. This can be helpful for [permissions management](../permissions/introduction.md#key-points-regarding-permissions) at scale.
1. Add groups to your JWT: `groups: ["group_name"]`.
1. In Metabase, go to the Admin panel and switch to **Setting > Authentication** tab.
1. Click the **Configure** button under JWT.
1. Under **Group Schema**, turn on the toggle **Synchronize Group Memberships**
1. Click **New mapping** and add the name of a JWT group.
You can configure JWT group assignments through Metabase's Admin interface, or by setting environment variables.
### Configure group mapping in Metabase
1. Add groups to your JWT: `groups: ["group_name"]`. The attribute key (e.g. `groups`) should match the **Group assignment attribute** in Metabase.
1. In Metabase JWT settings, under **Group Sync**, toggle on **Synchronize Group Memberships**
1. If the group names in your JWT match the Metabase group names, they will be synced automatically, and you don't need to set up mappings manually.
1. Otherwise, click **New mapping** and add the name of a JWT group.
1. In the row that appears, click the dropdown to pick the Metabase group(s) that this should map to.
![Metabase JWT group mappings](./images/jwt-groups.png)
1. Repeat this for each of the groups you want to map.
Alternatively, you can define the mappings between JWT and Metabase groups using the [environment variable `MB_JWT_GROUP_MAPPINGS`](../configuring-metabase/environment-variables.md#mb_jwt_group_mappings). It accepts a JSON object where the keys are JWT groups and the values are lists of Metabase groups IDs. For example:
### Configure group mapping through environment variables
```
MB_JWT_GROUP_MAPPINGS='{"extHR":[7], "extSales":[3,4]}'
```
You can use the following environment variables to configure JTW group mappings instead of configuring them in Metabase's Admin settings:
where `extHR`, `extSales` are names of JWT groups and 3,4,7 are IDs of Metabase groups.
- [`MB_JWT_ATTRIBUTE_GROUPS`](../configuring-metabase/environment-variables#mb_jwt_attribute_groups) to specify the key to retrieve the JWT user’s groups;
You can find Metabase Group ID in the URL for the group page, like `http://your-metabase-url/admin/people/groups/<ID>`. "All Users" group has ID 1 and "Administrators" group has ID 2.
- [`MB_JWT_GROUP_SYNC`](../configuring-metabase/environment-variables.md#mb_jwt_group_sync) to turn group sync on or off (sync is off by default).
You can also use the [environment variable `MB_JWT_GROUP_SYNC`](../configuring-metabase/environment-variables.md#mb_jwt_group_sync) to turn group sync on or off.
```
MB_JWT_GROUP_SYNC=true
```
```
MB_JWT_GROUP_SYNC=true
```
- [`MB_JWT_GROUP_MAPPINGS`](../configuring-metabase/environment-variables.md#mb_jwt_group_mappings) to configure group mapping. It accepts a JSON object where the keys are JWT groups and the values are lists of Metabase groups IDs. For example:
```
MB_JWT_GROUP_MAPPINGS='{"extHR":[7], "extSales":[3,4]}'
```
where `extHR`, `extSales` are names of JWT groups and 3,4,7 are IDs of Metabase groups.
You can find Metabase Group ID in the URL for the group page, like `http://your-metabase-url/admin/people/groups/<ID>`. "All Users" group has ID 1 and "Administrators" group has ID 2.
### If group mappings are not specified, Metabase will match groups by name
If you don't specify any group mappings in Metabase's Admin settings or via `MB_JWT_GROUP_MAPPINGS` environment variables, then Metabase will try to assign Metabase groups to users based on the matching names. If the names of groups in the JWT group attribute array match Metabase group names exactly (e.g. both are `"Sales"`), then the groups will be mapped automatically.
If you add group mappings manually, Metabase will _not_ try to also match groups by names.
## Creating Metabase accounts with SSO

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -1138,20 +1138,20 @@ describe("issue 49882", () => {
});
it("should update currently selected suggestion when suggestions list is updated (metabase#49882-4)", () => {
const selectProductRating =
const selectProductVendor =
"{downarrow}{downarrow}{downarrow}{downarrow}{downarrow}";
H.enterCustomColumnDetails({
formula: `[Produ${selectProductRating}`,
formula: `[Produ${selectProductVendor}`,
blur: false,
});
H.CustomExpressionEditor.completion("Product → Rating").should(
H.CustomExpressionEditor.completion("Product → Vendor").should(
"be.visible",
);
H.CustomExpressionEditor.acceptCompletion("tab");
H.CustomExpressionEditor.value().should("equal", "[Product → Rating]");
H.CustomExpressionEditor.value().should("equal", "[Product → Vendor]");
});
});

View File

@@ -16,7 +16,7 @@ describe(suiteTitle, () => {
});
it("auto-enables the enable-embedding-simple settings", () => {
cy.visit("/embed-js");
cy.visit("/embed-iframe");
cy.log("simple embedding toast should be shown");
H.undoToast()

View File

@@ -28,7 +28,7 @@ export const visitNewEmbedPage = ({
params.append("locale", locale);
}
cy.visit("/embed-js?" + params);
cy.visit("/embed-iframe?" + params);
if (dismissEmbedTerms) {
cy.log("simple embedding terms card should be shown");

View File

@@ -1075,6 +1075,69 @@ describe("scenarios > question > filter", () => {
H.popover().findByText("Created At").should("be.visible");
});
it("should allow selecting a field on the source table using the table prefix", () => {
H.openOrdersTable({ mode: "notebook" });
H.filter({ mode: "notebook" });
H.popover().within(() => {
cy.log("Columns from the source table should be visible");
filter("Orders");
verifySectionOrder(["Orders", "User"]);
verifyItemOrder(["ID", "Subtotal", "Tax", "Total"]);
cy.log("Columns from the source table should be possible to filter");
filter("Orders Sub");
verifySectionOrder(["Orders"]);
verifyItemOrder(["Subtotal", "Quantity", "User ID"]);
cy.log("Source table should match fuzzily");
filter("Arders");
verifySectionOrder(["User", "Orders"]);
verifyItemOrder(["Address", "ID", "Subtotal", "Tax", "Total"]);
cy.log("Source table should match fuzzily with field name");
filter("Ardors Sub");
verifySectionOrder(["Orders", "User"]);
verifyItemOrder(["Subtotal", "Address"]);
cy.log("Source table should match fuzzily with a fuzzy column name");
filter("Arders Sab");
verifySectionOrder(["Orders", "User"]);
verifyItemOrder(["Subtotal", "Address"]);
cy.log("It should match fields with spaces");
filter("Orders User ID");
verifySectionOrder(["Orders"]);
verifyItemOrder(["User ID", "Product ID"]);
cy.log("It should match fields directly");
filter("Ean");
verifySectionOrder(["Product", "Orders", "User"]);
verifyItemOrder(["Ean", "Vendor", "Created At"]);
});
function filter(searchText) {
cy.findByPlaceholderText("Find...").clear().type(searchText);
}
function verifySectionOrder(names) {
names.forEach((name, index) => {
cy.findAllByTestId("list-section-header")
.should("have.length.gte", names.length)
.eq(index)
.should("have.text", name);
});
}
function verifyItemOrder(names) {
names.forEach((name, index) => {
cy.findAllByTestId("dimension-list-item")
.should("have.length.gte", names.length)
.eq(index)
.should("have.text", name);
});
}
});
it("should allow picking custom expressions in filter picker", () => {
H.openOrdersTable({ mode: "notebook" });
H.filter({ mode: "notebook" });

View File

@@ -42,6 +42,7 @@ type Props<
role?: string;
searchProp?: SearchProps<TItem>;
searchable?: boolean | ((section: Section) => boolean | undefined);
fuzzySearch?: boolean;
sections: TSection[];
style?: CSSProperties;
@@ -265,6 +266,7 @@ export class AccordionList<
section?.items && section.items.length > 10,
sections,
searchProp,
fuzzySearch,
} = this.props;
const { searchText } = this.state;
@@ -289,6 +291,7 @@ export class AccordionList<
const sortedSections = searchFilter({
sections,
searchText,
fuzzySearch,
searchProp,
});

View File

@@ -88,6 +88,25 @@ describe("AccordionList", () => {
assertAbsence(["Foo", "Bar", "Baz"]);
});
it("should filter items when searched with fuzzySearch", () => {
render(
<AccordionList<Item>
sections={SECTIONS}
searchable
fuzzySearch
searchProp={["name"]}
/>,
);
const SEARCH_FIELD = screen.getByPlaceholderText("Find...");
fireEvent.change(SEARCH_FIELD, { target: { value: "Fob" } });
assertPresence(["Foo"]);
assertAbsence(["Bar", "Baz"]);
fireEvent.change(SEARCH_FIELD, { target: { value: "Something Else" } });
assertAbsence(["Foo", "Bar", "Baz"]);
});
it("should correctly select items when searching", () => {
render(
<AccordionList<Item>

View File

@@ -1,4 +1,5 @@
import { shallowEqual } from "@mantine/hooks";
import Fuse from "fuse.js";
import { getIn } from "icepick";
import { type ReactNode, isValidElement } from "react";
import { isFragment } from "react-is";
@@ -136,6 +137,7 @@ export function isReactNode(x: unknown): x is ReactNode {
type FilterOptions<TItem extends Item, TSection extends Section<TItem>> = {
sections: TSection[];
searchProp?: SearchProps<TItem>;
fuzzySearch?: boolean;
searchText: string;
};
@@ -161,11 +163,13 @@ export function searchFilter<
sections,
searchText,
searchProp = ["name", "displayName"] as unknown as SearchProps<TItem>,
fuzzySearch = false,
}: FilterOptions<TItem, TSection>) {
const strategy = searchStrategy({
sections,
searchText,
searchProp,
fuzzySearch,
});
const items = getSearchItems<TItem, TSection>(sections);
const scores = strategy({ items, searchText, searchProp });
@@ -186,9 +190,12 @@ const getSearchItems = memoize(function <
*/
function searchStrategy<TItem extends Item, TSection extends Section<TItem>>({
searchText,
fuzzySearch,
}: FilterOptions<TItem, TSection>): SearchStrategy<TItem> {
if (searchText === "") {
return alwaysMatch;
} else if (fuzzySearch) {
return searchFuzzy;
} else {
return searchSubstring;
}
@@ -207,6 +214,29 @@ const alwaysMatch = function <TItem extends Item>(): ItemScores<TItem> {
};
};
/**
* searchFuzzy is a SearchStrategy that use Fuse.js to perform fuzzy search.
*/
const searchFuzzy = memoize(function <TItem extends Item>({
items,
searchText,
searchProp,
}: SearchOptions<TItem>): ItemScores<TItem> {
const searchIndex = getFuzzySearchIndex({ items, searchProp });
const results = searchIndex.search(searchText, { limit: 50 });
const scores = new Map<TItem, number>();
for (const result of results) {
scores.set(result.item, result.score ?? 1);
}
return {
threshold: SEARCH_SCORE_THRESHOLD,
score(item: TItem) {
return scores.get(item) ?? 1;
},
};
});
/**
* searchSubstring is a SearchStrategy that performs a simple (lowercase) substring
* search.
@@ -281,6 +311,26 @@ function sortAndFilterItems<TItem extends Item>(
.sort((a, b) => a.itemScore - b.itemScore);
}
/**
* Build the Fuse.js index for fuzzy search.
*
* This is memoized to avoid rebuilding the index for every keystroke.
*/
const getFuzzySearchIndex = memoize(function <TItem extends Item>({
items,
searchProp,
}: {
items: TItem[];
searchProp: SearchProps<TItem>;
}) {
const keys = Array.isArray(searchProp) ? searchProp : [searchProp];
return new Fuse<TItem>(items, {
keys,
includeScore: true,
isCaseSensitive: false,
});
});
/**
* Memoizes a function based on shallow equality of its argument.
*

View File

@@ -360,6 +360,7 @@ export function AggregationPicker({
maxHeight={Infinity}
itemTestId="dimension-list-item"
globalSearch
fuzzySearch
/>
);
}

View File

@@ -102,7 +102,7 @@ const NewItemMenuView = ({
<Menu.Item
key="embed"
component={ForwardRefLink}
to="/embed-js"
to="/embed-iframe"
leftSection={<Icon name="embed" />}
rightSection={<Badge size="xs">{t`Beta`}</Badge>}
>

View File

@@ -341,6 +341,7 @@ export function QueryColumnPicker({
width={width}
globalSearch={!disableSearch}
searchable={!disableSearch}
fuzzySearch
/>
</DelayGroup>
);

View File

@@ -117,12 +117,14 @@ describe("nav > containers > Navbar > Core App", () => {
expect(screen.queryByTestId("main-navbar-root")).not.toBeInTheDocument();
});
["question/1", "model/1", "dashboard/1", "embed-js"].forEach((pathname) => {
it(`should be hidden on initial load for a ${pathname}`, async () => {
await setup({ pathname: `/${pathname}` });
await expectNavbarClosed();
});
});
["question/1", "model/1", "dashboard/1", "embed-iframe"].forEach(
(pathname) => {
it(`should be hidden on initial load for a ${pathname}`, async () => {
await setup({ pathname: `/${pathname}` });
await expectNavbarClosed();
});
},
);
it("should hide when visiting a question", async () => {
const store = await setup({ pathname: "/" });

View File

@@ -35,7 +35,7 @@ export function suggestAggregations({
(agg) => expressionClauseCompletion(agg, { type: "aggregation" }),
);
const matcher = fuzzyMatcher(aggregations);
const matcher = fuzzyMatcher({ options: aggregations });
return function (context: CompletionContext) {
const source = context.state.doc.toString();
@@ -56,6 +56,7 @@ export function suggestAggregations({
from: token.start,
to: token.end,
options,
filter: false,
};
};
}

View File

@@ -58,6 +58,7 @@ describe("suggestAggregations", () => {
const RESULTS = {
from: 0,
to: 4,
filter: false,
options: [
{
apply: expect.any(Function),
@@ -123,6 +124,7 @@ describe("suggestAggregations", () => {
from: 0,
to: 11,
options: [],
filter: false,
});
});
@@ -135,6 +137,7 @@ describe("suggestAggregations", () => {
expect(results).toEqual({
from: 0,
to: 11,
filter: false,
options: [
{
label: "StandardDeviation",

View File

@@ -1,6 +1,7 @@
import type { CompletionContext } from "@codemirror/autocomplete";
import { getColumnIcon } from "metabase/common/utils/columns";
import { isNotNull } from "metabase/lib/types";
import * as Lib from "metabase-lib";
import { formatIdentifier } from "../identifier";
@@ -26,6 +27,12 @@ export function suggestFields({
type: "field",
label: formatIdentifier(displayInfo.longDisplayName),
displayLabel: displayInfo.longDisplayName,
displayLabelWithTable: [
displayInfo.table?.displayName,
displayInfo.displayName,
]
.filter(isNotNull)
.join(" "),
icon: getColumnIcon(column),
column,
};
@@ -35,7 +42,10 @@ export function suggestFields({
return null;
}
const matcher = fuzzyMatcher(columns);
const matcher = fuzzyMatcher({
options: columns,
keys: ["displayLabel", { name: "displayLabelWithTable", weight: 0.25 }],
});
return function (context: CompletionContext): CompletionResult | null {
const source = context.state.doc.toString();
@@ -61,6 +71,7 @@ export function suggestFields({
from: token.start,
to: token.end,
options,
filter: false,
};
};
}

View File

@@ -84,10 +84,12 @@ describe("suggestFields", () => {
const RESULTS = {
from: 0,
to: 3,
filter: false,
options: [
{
label: "[Email]",
displayLabel: "Email",
displayLabelWithTable: "Table Email",
matches: [[0, 2]],
type: "field",
icon: "list",
@@ -96,6 +98,7 @@ describe("suggestFields", () => {
{
label: "[Seats]",
displayLabel: "Seats",
displayLabelWithTable: "Table Seats",
matches: [[1, 2]],
type: "field",
icon: "int",
@@ -112,6 +115,7 @@ describe("suggestFields", () => {
{
label: "[Email]",
displayLabel: "Email",
displayLabelWithTable: "Table Email",
type: "field",
icon: "list",
column: expect.any(Object),
@@ -119,6 +123,7 @@ describe("suggestFields", () => {
{
label: "[Name]",
displayLabel: "Name",
displayLabelWithTable: "Table Name",
type: "field",
icon: "list",
column: expect.any(Object),
@@ -126,6 +131,7 @@ describe("suggestFields", () => {
{
label: "[Seats]",
displayLabel: "Seats",
displayLabelWithTable: "Table Seats",
type: "field",
icon: "int",
column: expect.any(Object),
@@ -199,51 +205,22 @@ describe("suggestFields", () => {
expect(result).toEqual({
from: 0,
to: 4,
filter: false,
options: [
{
type: "field",
label: "[User ID]",
displayLabel: "User ID",
displayLabelWithTable: "Orders User ID",
icon: "connections",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → Address]",
displayLabel: "User → Address",
icon: "string",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → City]",
displayLabel: "User → City",
icon: "location",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → Email]",
displayLabel: "User → Email",
icon: "string",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → ID]",
displayLabel: "User → ID",
icon: "label",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → Latitude]",
displayLabel: "User → Latitude",
displayLabelWithTable: "People Latitude",
icon: "location",
column: expect.any(Object),
matches: [[0, 2]],
@@ -252,14 +229,52 @@ describe("suggestFields", () => {
type: "field",
label: "[User → Longitude]",
displayLabel: "User → Longitude",
displayLabelWithTable: "People Longitude",
icon: "location",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → Address]",
displayLabel: "User → Address",
displayLabelWithTable: "People Address",
icon: "string",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → City]",
displayLabel: "User → City",
displayLabelWithTable: "People City",
icon: "location",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → Email]",
displayLabel: "User → Email",
displayLabelWithTable: "People Email",
icon: "string",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → ID]",
displayLabel: "User → ID",
displayLabelWithTable: "People ID",
icon: "label",
column: expect.any(Object),
matches: [[0, 2]],
},
{
type: "field",
label: "[User → Name]",
displayLabel: "User → Name",
displayLabelWithTable: "People Name",
icon: "string",
column: expect.any(Object),
matches: [[0, 2]],
@@ -268,6 +283,7 @@ describe("suggestFields", () => {
type: "field",
label: "[User → Password]",
displayLabel: "User → Password",
displayLabelWithTable: "People Password",
icon: "string",
column: expect.any(Object),
matches: [[0, 2]],
@@ -276,6 +292,7 @@ describe("suggestFields", () => {
type: "field",
label: "[User → Source]",
displayLabel: "User → Source",
displayLabelWithTable: "People Source",
icon: "string",
column: expect.any(Object),
matches: [[0, 2]],
@@ -284,6 +301,7 @@ describe("suggestFields", () => {
type: "field",
label: "[User → State]",
displayLabel: "User → State",
displayLabelWithTable: "People State",
icon: "location",
column: expect.any(Object),
matches: [[0, 2]],
@@ -292,6 +310,7 @@ describe("suggestFields", () => {
type: "field",
label: "[User → Zip]",
displayLabel: "User → Zip",
displayLabelWithTable: "People Zip",
icon: "location",
column: expect.any(Object),
matches: [[0, 2]],
@@ -300,6 +319,7 @@ describe("suggestFields", () => {
type: "field",
label: "[User → Birth Date]",
displayLabel: "User → Birth Date",
displayLabelWithTable: "People Birth Date",
icon: "calendar",
column: expect.any(Object),
matches: [[0, 2]],
@@ -308,6 +328,7 @@ describe("suggestFields", () => {
type: "field",
label: "[User → Created At]",
displayLabel: "User → Created At",
displayLabelWithTable: "People Created At",
icon: "calendar",
column: expect.any(Object),
matches: [[0, 2]],
@@ -358,11 +379,13 @@ describe("suggestFields", () => {
expect(result).toEqual({
from: 0,
to: 3,
filter: false,
options: expect.any(Array),
});
expect(result?.options[0]).toEqual({
displayLabel: "Foo → Body",
displayLabelWithTable: "Reviews Body",
label: "[Foo → Body]",
type: "field",
icon: "string",
@@ -371,6 +394,7 @@ describe("suggestFields", () => {
});
expect(result?.options[1]).toEqual({
displayLabel: "Foo → ID",
displayLabelWithTable: "Reviews ID",
label: "[Foo → ID]",
type: "field",
icon: "label",
@@ -412,11 +436,13 @@ describe("suggestFields", () => {
expect(result).toEqual({
from: 0,
to: 1,
filter: false,
options: [
{
type: "field",
label: "[Total]",
displayLabel: "Total",
displayLabelWithTable: "Orders Total",
icon: "int",
column: expect.any(Object),
matches: [
@@ -428,6 +454,7 @@ describe("suggestFields", () => {
type: "field",
label: "[Count]",
displayLabel: "Count",
displayLabelWithTable: "Count",
icon: "int",
column: expect.any(Object),
matches: [[4, 4]],

View File

@@ -37,7 +37,7 @@ export function suggestFunctions({ expressionMode, query, metadata }: Options) {
(func) => expressionClauseCompletion(func, { type: "function" }),
);
const matcher = fuzzyMatcher(functions);
const matcher = fuzzyMatcher({ options: functions });
return function (context: CompletionContext) {
const source = context.state.doc.toString();
@@ -62,6 +62,7 @@ export function suggestFunctions({ expressionMode, query, metadata }: Options) {
from: token.start,
to: token.end,
options,
filter: false,
};
};
}

View File

@@ -35,6 +35,7 @@ describe("suggestFunctions", () => {
const RESULTS = {
from: 0,
to: 4,
filter: false,
options: [
{
label: "concat",

View File

@@ -28,7 +28,7 @@ export function suggestMetrics({ expressionMode, query, stageIndex }: Options) {
return null;
}
const matcher = fuzzyMatcher(metrics);
const matcher = fuzzyMatcher({ options: metrics });
return function (context: CompletionContext) {
const source = context.state.doc.toString();

View File

@@ -27,7 +27,7 @@ export function suggestSegments({ query, stageIndex }: Options) {
return null;
}
const matcher = fuzzyMatcher(segments);
const matcher = fuzzyMatcher({ options: segments });
return function (context: CompletionContext) {
const source = context.state.doc.toString();

View File

@@ -1,5 +1,6 @@
import { snippetCompletion } from "@codemirror/autocomplete";
import Fuse from "fuse.js";
import _ from "underscore";
import { CALL, FIELD, IDENTIFIER, type Token } from "../pratt";
import type { MBQLClauseFunctionConfig } from "../types";
@@ -34,9 +35,15 @@ export function expressionClauseSnippet(clause: MBQLClauseFunctionConfig) {
return `${clause.displayName}(${args})`;
}
export function fuzzyMatcher(options: ExpressionSuggestion[]) {
export const fuzzyMatcher = _.memoize(function ({
options,
keys = ["displayLabel"],
}: {
options: ExpressionSuggestion[];
keys?: (string | { name: string; weight?: number })[];
}) {
const fuse = new Fuse(options, {
keys: ["displayLabel"],
keys,
includeScore: true,
includeMatches: true,
});
@@ -44,14 +51,24 @@ export function fuzzyMatcher(options: ExpressionSuggestion[]) {
return function (word: string) {
return fuse
.search(word)
.filter((result) => (result.score ?? 0) <= 0.6)
.sort((a, b) => (a.score ?? 0) - (b.score ?? 0))
.map((result) => {
result.item.matches =
result.matches?.flatMap((match) => match.indices) ?? [];
return result.item;
const { item, matches = [] } = result;
const key = matches[0]?.key;
const indices = matches[0]?.indices;
// We need to preserve item identity here, so we need to return the original item
// possible with updated values for displayLabel and matches
// item.displayLabel = displayLabel ?? item.displayLabel;
if (key === "displayLabel") {
item.matches = Array.from(indices ?? []);
}
return item;
});
};
}
}, JSON.stringify);
export function isIdentifier(token: Token | null) {
return token != null && (token.type === IDENTIFIER || token.type === CALL);

View File

@@ -154,6 +154,7 @@ export function FilterColumnPicker({
itemTestId="dimension-list-item"
searchProp={SEARCH_PROP}
globalSearch
fuzzySearch
/>
</DelayGroup>
);

View File

@@ -76,7 +76,7 @@ const errorPage = handleActions(
// regexr.com/7r89i
// A word boundary is added to /model so it doesn't match /browse/models
const PATH_WITH_COLLAPSED_NAVBAR =
/\/(model\b|question|dashboard|metabot|embed-js).*/;
/\/(model\b|question|dashboard|metabot|embed-iframe).*/;
export function isNavbarOpenForPathname(pathname: string, prevState: boolean) {
return (

View File

@@ -179,7 +179,7 @@ export const getRoutes = (store) => {
/>
<Route
path="embed-js"
path="embed-iframe"
component={PLUGIN_EMBEDDING_IFRAME_SDK_SETUP.SdkIframeEmbedSetup}
/>

View File

@@ -728,10 +728,9 @@
(lib.util/clause-of-type? lhs-or-rhs :value))
(mu/defn join-condition-lhs-or-rhs-column? :- :boolean
"Whether this LHS or RHS expression is a `:field` or `:expression` reference."
"Whether this LHS or RHS expression is a `:field` reference."
[lhs-or-rhs :- [:maybe ::lib.schema.expression/expression]]
(or (lib.util/clause-of-type? lhs-or-rhs :field)
(lib.util/clause-of-type? lhs-or-rhs :expression)))
(lib.util/field-clause? lhs-or-rhs))
(mu/defn filter-args-display-name :- :string
"Provides a reasonable display name for the `filter-clause` excluding the column-name.

View File

@@ -811,14 +811,14 @@
(joins query stage-number))]
(->> (lib.metadata.calculation/visible-columns query stage-number
(lib.util/query-stage query stage-number)
{:include-implicitly-joinable? false})
{:include-expressions? false
:include-implicitly-joinable? false})
(remove (fn [col]
(when-let [col-join-alias (lib.join.util/current-join-alias col)]
(contains? join-aliases-to-ignore col-join-alias))))
(mark-selected-column query
stage-number
(when (or (lib.util/clause-of-type? lhs-expression-or-nil :field)
(lib.util/clause-of-type? lhs-expression-or-nil :expression))
(when (lib.util/field-clause? lhs-expression-or-nil)
lhs-expression-or-nil))
sort-join-condition-columns))))

View File

@@ -81,21 +81,21 @@
"Function called whenever we have a cache hit. Normally just does boring logging but dynamic so we can test this
stuff."
[k]
(log/debug (str (str/join (repeat *cache-depth* "| ")) (u/colorize :green "HIT: ") (name (first k)) " " (hash (rest k)))))
(log/debug (str (str/join (repeat *cache-depth* "| ")) (u/format-color :green "Found %s" (pr-str k)))))
(defn ^:dynamic *cache-miss-hook*
"Function called whenever we have a cache miss. Normally just does boring logging but dynamic so we can test this
stuff."
[k]
(log/debug (str (str/join (repeat *cache-depth* "| ")) (u/colorize :red "MISS: ") (name (first k)) " " (hash (rest k)))))
(log/debug (str (str/join (repeat *cache-depth* "| ")) (u/format-color :yellow "Calculating %s" (pr-str k)))))
(mu/defn do-with-cached-value
(mu/defn do-with-cached-value :- :some
"Impl for [[with-cached-value]]."
[metadata-providerable :- ::lib.metadata.protocols/metadata-providerable
k :- ::cache-key
thunk :- [:=> [:cat] :any]]
thunk :- [:=> [:cat] :some]]
(binding [*cache-depth* (inc *cache-depth*)]
(log/debug (str (str/join (repeat *cache-depth* "| ")) (u/colorize :cyan "GET: ") (name (first k)) " " (hash (rest k))))
(log/debug (str (str/join (repeat *cache-depth* "| ")) (u/format-color :red "Get %s" (pr-str k))))
(let [cached-v (cached-value metadata-providerable k ::not-found)]
(if-not (= cached-v ::not-found)
(do

View File

@@ -143,8 +143,6 @@
(cached-value cache k not-found))
(cache-value! [_this k v]
(cache-value! cache k v))
(has-cache? [_this]
true)
#?(:clj Object :cljs IEquiv)
(#?(:clj equals :cljs -equiv) [_this another]

View File

@@ -61,10 +61,6 @@
(when-let [cached-provider (first (cached-providers metadata-providers))]
(metadata.protocols/cache-value! cached-provider k v)))
(defn- has-cache? [metadata-providers]
(some metadata.protocols/has-cache?
(cached-providers metadata-providers)))
(defn- store-metadata! [metadata-providers metadata]
(when-first [provider (cached-providers metadata-providers)]
(metadata.protocols/store-metadata! provider metadata)))
@@ -117,8 +113,6 @@
(cached-value metadata-providers k not-found))
(cache-value! [_this k v]
(cache-value! metadata-providers k v))
(has-cache? [_this]
(has-cache? metadata-providers))
metadata.protocols/InvocationTracker
(invoked-ids [_this metadata-type]

View File

@@ -60,20 +60,13 @@
lib.metadata.protocols/CachedMetadataProvider
(cached-metadatas [_this metadata-type ids]
(when (lib.metadata.protocols/cached-metadata-provider? metadata-provider)
(lib.metadata.protocols/cached-metadatas metadata-provider metadata-type ids)))
(lib.metadata.protocols/cached-metadatas metadata-provider metadata-type ids))
(store-metadata! [_this object]
(when (lib.metadata.protocols/cached-metadata-provider? metadata-provider)
(lib.metadata.protocols/store-metadata! metadata-provider object)))
(lib.metadata.protocols/store-metadata! metadata-provider object))
(cached-value [_this k not-found]
(when (lib.metadata.protocols/cached-metadata-provider? metadata-provider)
(lib.metadata.protocols/cached-value metadata-provider k not-found)))
(lib.metadata.protocols/cached-value metadata-provider k not-found))
(cache-value! [_this k v]
(when (lib.metadata.protocols/cached-metadata-provider? metadata-provider)
(lib.metadata.protocols/cache-value! metadata-provider k v)))
(has-cache? [_this]
(when (lib.metadata.protocols/cached-metadata-provider? metadata-provider)
(lib.metadata.protocols/has-cache? metadata-provider)))
(lib.metadata.protocols/cache-value! metadata-provider k v))
#?(:clj Object :cljs IEquiv)
(#?(:clj equals :cljs -equiv) [_this another]

View File

@@ -165,15 +165,10 @@
(cached-value [cached-metadata-provider k not-found]
"Fetch a general cached value stored by [[cache-value!]] with the key `k`.")
(cache-value! [cached-metadata-provider k v]
"Store a general cached value `v` under the key `k`.")
(has-cache? [cached-metadata-provider]
"Whether this metadata provider actually has a cache or not. (Some metadata providers like
ComposedMetadataProvider implement this method but can only cache stuff if one of the providers they wrap is a cached
metadata provider.)"))
"Store a general cached value `v` under the key `k`."))
(defn cached-metadata-provider?
"Whether `x` satisfies the [[CachedMetadataProvider]] protocol. This does not necessarily mean it actually caches
anything! Check [[cached-metadata-provider-with-cache?]] if that's what you want to know."
"Whether `x` is a valid [[CachedMetadataProvider]]."
[x]
#?(:clj (extends? CachedMetadataProvider (class x))
:cljs (satisfies? CachedMetadataProvider x)))
@@ -183,17 +178,6 @@
{:error/message "A CachedMetadataProvider"}
#'cached-metadata-provider?])
(defn cached-metadata-provider-with-cache?
"Whether `x` is a [[CachedMetadataProvider]] that [[has-cache?]]."
[x]
(and (cached-metadata-provider? x)
(has-cache? x)))
(mr/def ::cached-metadata-provider-with-cache
[:fn
{:error/message "A CachedMetadataProvider with a cache"}
#'cached-metadata-provider-with-cache?])
(mu/defn store-metadatas!
"Convenience. Store several metadata maps at once."
[cached-metadata-provider :- ::cached-metadata-provider

View File

@@ -75,7 +75,7 @@
[query stage-number metric-metadata]
(merge
((get-method lib.metadata.calculation/display-info-method :default) query stage-number metric-metadata)
(select-keys metric-metadata [:description :aggregation-position :display-name])))
(select-keys metric-metadata [:description :aggregation-position])))
(defmethod lib.metadata.calculation/display-info-method :metric
[query stage-number [_tag opts metric-id-or-name]]

View File

@@ -286,10 +286,10 @@
"Ensure `a-query` has a cached metadata provider (needed so we can use the general `cached-value` and `cache-value!`
facilities; wrap the current metadata in one that adds caching if needed."
[a-query]
(let [mp (:lib/metadata a-query)
cached-mp? (lib.metadata.protocols/cached-metadata-provider-with-cache? mp)]
(cond-> a-query
(and mp (not cached-mp?)) (update :lib/metadata lib.metadata.cached-provider/cached-metadata-provider))))
(cond-> a-query
(and (:lib/metadata a-query)
(not (lib.metadata.protocols/cached-metadata-provider? (:lib/metadata a-query))))
(update :lib/metadata lib.metadata.cached-provider/cached-metadata-provider)))
(mu/defn query :- ::lib.schema/query
"Create a new MBQL query from anything that could conceptually be an MBQL query, like a Database or Table or an

View File

@@ -23,19 +23,20 @@
We determine which direct database field references are referencing active fields and remove the others.
Then we recursively remove references to the removed columns."
[query :- ::lib.schema/query]
(lib.walk/walk-stages
query
(fn [_query stage-path stage]
(letfn [(resolve-field-ref [field-ref]
(when (= (first field-ref) :field)
;; resolve metadata in the ORIGINAL query so removing fields upstream doesn't mess up our metadata
;; resolution
(lib.walk/apply-f-for-stage-at-path lib.field.resolution/resolve-field-ref query stage-path field-ref)))
(inactive-field-ref? [field-ref]
(when-let [col (resolve-field-ref field-ref)]
(false? (:active col))))
(update-fields [fields]
(not-empty (into [] (remove inactive-field-ref?) fields)))]
(if (empty? (:fields stage))
stage
(u/assoc-dissoc stage :fields (update-fields (:fields stage))))))))
(let [original-query query]
(lib.walk/walk-stages
query
(fn [_query stage-path stage]
(letfn [(resolve-field-ref [field-ref]
(when (= (first field-ref) :field)
;; resolve metadata in the ORIGINAL query so removing fields upstream doesn't mess up our metadata
;; resolution
(lib.walk/apply-f-for-stage-at-path lib.field.resolution/resolve-field-ref original-query stage-path field-ref)))
(inactive-field-ref? [field-ref]
(when-let [col (resolve-field-ref field-ref)]
(false? (:active col))))
(update-fields [fields]
(not-empty (into [] (remove inactive-field-ref?) fields)))]
(if (empty? (:fields stage))
stage
(u/assoc-dissoc stage :fields (update-fields (:fields stage)))))))))

View File

@@ -1,6 +1,5 @@
(ns metabase.query-processor.preprocess
(:require
[metabase.config.core :as config]
[metabase.legacy-mbql.schema :as mbql.s]
[metabase.lib.core :as lib]
[metabase.lib.schema :as lib.schema]
@@ -51,8 +50,6 @@
[metabase.util.log :as log]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
;;; the following helper functions are temporary, to aid in the transition from a legacy MBQL QP to a pMBQL QP. Each
;;; individual middleware function is wrapped in either [[ensure-legacy]] or [[ensure-pmbql]], and will then see the
;;; flavor of MBQL it is written for.
@@ -170,12 +167,6 @@
fn-name)
middleware-fn))
(def ^:private ^Long slow-middleware-warning-threshold-ms
"Warn about slow middleware if it takes longer than this many milliseconds."
(if config/is-prod?
1000 ; this is egregious but we don't want to spam the logs with stuff like this in prod
100))
(mu/defn preprocess :- [:map
[:database ::lib.schema.id/database]]
"Fully preprocess a query, but do not compile it to a native query or execute it."
@@ -191,23 +182,19 @@
([query middleware-fn]
(try
(assert (ifn? middleware-fn))
(let [start-timer (u/start-timer)]
;; make sure the middleware returns a valid query... this should be dev-facing only so no need to i18n
(u/prog1 (middleware-fn query)
(qp.debug/debug>
(when-not (= <> query)
(let [middleware-fn-name (middleware-fn-name middleware-fn)]
(list middleware-fn-name '=> <>
^{:portal.viewer/default :portal.viewer/diff}
[(or (-> <> meta :converted-form) query)
<>]))))
;; make sure the middleware returns a valid query... this should be dev-facing only so no need to i18n
(u/prog1 (middleware-fn query)
(let [duration-ms (u/since-ms start-timer)]
(when (> duration-ms slow-middleware-warning-threshold-ms)
(log/warnf "Slow middleware: %s took %s" (middleware-fn-name middleware-fn) (u/format-milliseconds duration-ms))))
(qp.debug/debug>
(when-not (= <> query)
(let [middleware-fn-name (middleware-fn-name middleware-fn)]
(list middleware-fn-name '=> <>
^{:portal.viewer/default :portal.viewer/diff}
[(or (-> <> meta :converted-form) query)
<>]))))
;; make sure the middleware returns a valid query... this should be dev-facing only so no need to i18n
(when-not (map? <>)
(throw (ex-info (format "Middleware did not return a valid query.")
{:fn (middleware-fn-name middleware-fn), :query query, :result <>, :type qp.error-type/qp})))))
(when-not (map? <>)
(throw (ex-info (format "Middleware did not return a valid query.")
{:fn (middleware-fn-name middleware-fn), :query query, :result <>, :type qp.error-type/qp}))))
(catch Throwable e
(let [middleware-fn (middleware-fn-name middleware-fn)]
(throw (ex-info (i18n/tru "Error preprocessing query in {0}: {1}" middleware-fn (ex-message e))

View File

@@ -16,8 +16,6 @@
[metabase.lib-be.metadata.jvm :as lib.metadata.jvm]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.cached-provider :as lib.metadata.cached-provider]
[metabase.lib.metadata.protocols :as lib.metadata.protocols]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.lib.schema.metadata :as lib.schema.metadata]
[metabase.query-processor.error-type :as qp.error-type]
@@ -118,26 +116,22 @@
::lib.schema.id/database
::lib.schema.metadata/metadata-providerable])
(defn- ensure-cached-metadata-provider [mp]
(if (lib.metadata.protocols/cached-metadata-provider-with-cache? mp)
mp
(lib.metadata.cached-provider/cached-metadata-provider mp)))
(mu/defn- ->metadata-provider :- ::lib.schema.metadata/metadata-provider
[database-id-or-metadata-providerable :- ::database-id-or-metadata-providerable]
(let [mp (if (pos-int? database-id-or-metadata-providerable)
(lib.metadata.jvm/application-database-metadata-provider database-id-or-metadata-providerable)
(lib.metadata/->metadata-provider database-id-or-metadata-providerable))]
(ensure-cached-metadata-provider mp)))
(if (integer? database-id-or-metadata-providerable)
(lib.metadata.jvm/application-database-metadata-provider database-id-or-metadata-providerable)
database-id-or-metadata-providerable))
(mu/defn- validate-existing-provider
"Impl for [[with-metadata-provider]]; if there's already a provider, just make sure we're not trying to change the
Database. We don't need to replace it."
[database-id-or-metadata-providerable :- ::database-id-or-metadata-providerable]
(let [old-provider (miscellaneous-value [::metadata-provider])]
(if (pos-int? database-id-or-metadata-providerable)
;; Database ID
(let [new-database-id database-id-or-metadata-providerable
(when-not (identical? old-provider database-id-or-metadata-providerable)
(let [new-database-id (if (integer? database-id-or-metadata-providerable)
database-id-or-metadata-providerable
(throw (ex-info "Cannot replace MetadataProvider with another one after it has been bound"
{:old-provider old-provider, :new-provider database-id-or-metadata-providerable})))
existing-database-id (u/the-id (lib.metadata/database old-provider))]
(when-not (= new-database-id existing-database-id)
(throw (ex-info (tru "Attempting to initialize metadata provider with new Database {0}. Queries can only reference one Database. Already referencing: {1}"
@@ -145,15 +139,7 @@
(pr-str existing-database-id))
{:existing-id existing-database-id
:new-id new-database-id
:type qp.error-type/invalid-query}))))
;; Metadata Providerable
(let [new-provider (-> database-id-or-metadata-providerable
lib.metadata/->metadata-provider
ensure-cached-metadata-provider)]
(when-not (= new-provider old-provider)
(throw (ex-info "Cannot replace MetadataProvider with another one after it has been bound"
{:old-provider old-provider, :new-provider database-id-or-metadata-providerable})))))))
:type qp.error-type/invalid-query})))))))
(mu/defn- set-metadata-provider!
"Create a new metadata provider and save it."

View File

@@ -900,18 +900,15 @@
(lib/+ rhs-product-price rhs-product-price))))
(deftest ^:parallel join-condition-lhs-or-rhs-column?-test
(let [query (-> (lib/query meta/metadata-provider (meta/table-metadata :orders))
(lib/expression "double-total" (lib/* (meta/field-metadata :orders :total) 2)))
(let [query (lib/query meta/metadata-provider (meta/table-metadata :orders))
products (meta/table-metadata :products)
lhs-columns (lib/join-condition-lhs-columns query products nil nil)
lhs-order-tax (m/find-first (comp #{"TAX"} :name) lhs-columns)
rhs-columns (lib/join-condition-rhs-columns query products nil nil)
rhs-product-price (m/find-first (comp #{"PRICE"} :name) rhs-columns)
lhs-custom-column (m/find-first (comp #{"double-total"} :name) lhs-columns)]
rhs-product-price (m/find-first (comp #{"PRICE"} :name) rhs-columns)]
(are [lhs-or-rhs] (true? (lib.fe-util/join-condition-lhs-or-rhs-column? lhs-or-rhs))
(lib/ref lhs-order-tax)
(lib/ref rhs-product-price)
(lib/ref lhs-custom-column))
(lib/ref rhs-product-price))
(are [lhs-or-rhs] (false? (lib.fe-util/join-condition-lhs-or-rhs-column? lhs-or-rhs))
(lib.expression/value 1)
(lib/+ lhs-order-tax 1)

View File

@@ -702,16 +702,15 @@
(lib/join-condition-lhs-columns query nil nil rhs)))))))
(deftest ^:parallel join-condition-lhs-columns-expression-test
(testing "Should include expressions in LHS columns"
(testing "Should not include expressions in LHS columns"
(let [query (-> (lib.tu/venues-query)
(lib/expression "double-price" (lib/* (meta/field-metadata :venues :price) 2)))]
(is (=? [{:name "ID"}
{:name "CATEGORY_ID"}
{:name "NAME"}
{:name "LATITUDE"}
{:name "LONGITUDE"}
{:name "PRICE"}
{:name "double-price"}]
(is (=? [{:lib/desired-column-alias "ID"}
{:lib/desired-column-alias "CATEGORY_ID"}
{:lib/desired-column-alias "NAME"}
{:lib/desired-column-alias "LATITUDE"}
{:lib/desired-column-alias "LONGITUDE"}
{:lib/desired-column-alias "PRICE"}]
(lib/join-condition-lhs-columns query nil nil nil))))))
(deftest ^:parallel join-condition-lhs-columns-with-previous-join-test
@@ -809,31 +808,7 @@
(lib/join-condition-lhs-columns query
join-for-query-with-join
(lib/+ (meta/field-metadata :venues :id) 1)
(lib/+ (meta/field-metadata :categories :id) 1)))))))
(testing "should mark custom columns from LHS columns as selected"
(let [query (-> (lib/query meta/metadata-provider (meta/table-metadata :orders))
(lib/expression "expr" (lib/* (meta/field-metadata :orders :total) 2)))
products (meta/table-metadata :products)
lhs-columns (lib/join-condition-lhs-columns query products nil nil)
lhs-custom-column (m/find-first (comp #{"expr"} :name) lhs-columns)
rhs-columns (lib/join-condition-rhs-columns query products nil nil)
rhs-product-price (m/find-first (comp #{"PRICE"} :name) rhs-columns)
query (lib/join query (lib/join-clause products [(lib/= lhs-custom-column rhs-product-price)]))]
(is (=? [{:name "ID"}
{:name "USER_ID"}
{:name "PRODUCT_ID"}
{:name "SUBTOTAL"}
{:name "TAX"}
{:name "TOTAL"}
{:name "DISCOUNT"}
{:name "CREATED_AT"}
{:name "QUANTITY"}
{:name "expr", :selected true}]
(map (partial lib/display-info query)
(lib/join-condition-lhs-columns query
(first (lib/joins query))
(lib/ref lhs-custom-column)
(lib/ref rhs-product-price))))))))
(lib/+ (meta/field-metadata :categories :id) 1))))))))
(deftest ^:parallel join-condition-rhs-columns-join-table-test
(testing "RHS columns when building a join against a Table"

View File

@@ -7,7 +7,6 @@
[metabase.lib.convert :as lib.convert]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.cached-provider :as lib.metadata.cached-provider]
[metabase.lib.metadata.calculation :as lib.metadata.calculation]
[metabase.lib.metadata.invocation-tracker :as lib.metadata.invocation-tracker]
[metabase.lib.metadata.protocols :as lib.metadata.protocols]
@@ -599,19 +598,9 @@
(is (lib.metadata.protocols/cached-metadata-provider? (lib.metadata/->metadata-provider query)))))))
(deftest ^:parallel automatically-wrap-metadata-providers-in-cached-metadata-provider-test-2
(testing "Re-wrap things that are CachedMetadataProviders IF they do not have a cache"
(testing "Don't re-wrap things that are already CachedMetadataProviders"
(let [mp (lib.metadata.invocation-tracker/invocation-tracker-provider
(lib.tu/mock-metadata-provider {}))]
(is (lib.metadata.protocols/cached-metadata-provider? mp))
(is (not (lib.metadata.protocols/cached-metadata-provider-with-cache? mp)))
(is (= (lib.metadata.cached-provider/cached-metadata-provider mp)
(:lib/metadata (#'lib.query/ensure-cached-metadata-provider {:lib/metadata mp})))))))
(deftest ^:parallel automatically-wrap-metadata-providers-in-cached-metadata-provider-test-3
(testing "Do-not re-wrap things that already have a cache"
(let [mp (lib.metadata.invocation-tracker/invocation-tracker-provider
(lib.metadata.cached-provider/cached-metadata-provider
(lib.tu/mock-metadata-provider {})))]
(is (lib.metadata.protocols/cached-metadata-provider? mp))
(is (lib.metadata.protocols/cached-metadata-provider-with-cache? mp))
(is (identical? mp (:lib/metadata (#'lib.query/ensure-cached-metadata-provider {:lib/metadata mp})))))))
(is (identical? mp
(:lib/metadata (#'lib.query/ensure-cached-metadata-provider {:lib/metadata mp})))))))

View File

@@ -3,7 +3,6 @@
[clojure.test :refer :all]
[metabase.lib.metadata.protocols :as lib.metadata.protocols]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util :as lib.tu]
[metabase.query-processor.store :as qp.store]
[metabase.util :as u]))
@@ -37,26 +36,6 @@
{:value (cached-value)
:eval-count @eval-count}))))))))
(deftest ^:parallel reuse-existing-store-fail-if-changing-db-test
(testing "Throw an exception if you try to change the store after one is already bound"
(let [mp-1 (lib.tu/mock-metadata-provider
{:database (assoc meta/database :id 2)})
mp-2 (lib.tu/mock-metadata-provider
{:database (assoc meta/database :id 3)})]
(qp.store/with-metadata-provider mp-1
(testing "Rebinding another MetadataProvider(able)"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"\QCannot replace MetadataProvider with another one after it has been bound\E"
(qp.store/with-metadata-provider mp-2
nil))))
(testing "Rebinding another Database ID"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"\QAttempting to initialize metadata provider with new Database 3. Queries can only reference one Database. Already referencing: 2\E"
(qp.store/with-metadata-provider 3
nil))))))))
(deftest ^:parallel caching-unique-key-test
(testing "caching should be unique for each key"
(let [eval-count (atom 0)

View File

@@ -3,12 +3,9 @@
[clojure.string :as str]
[clojure.test :refer :all]
[clojure.walk :as walk]
[medley.core :as m]
[metabase.driver :as driver]
[metabase.driver.h2 :as h2]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.calculation :as lib.metadata.calculation]
[metabase.lib.test-metadata :as meta]
[metabase.lib.test-util :as lib.tu]
[metabase.lib.test-util.macros :as lib.tu.macros]
@@ -968,127 +965,6 @@
(add/add-alias-info {:globally-unique-join-aliases? true})
:query))))))))
;;; adapted from [[metabase.query-processor-test.model-test/model-self-join-test]]
(deftest ^:parallel model-duplicate-joins-test
(testing "Field references from model joined a second time can be resolved (#48639)"
(let [mp meta/metadata-provider
mp (lib.tu/mock-metadata-provider
mp
{:cards [{:id 1
:dataset-query
(-> (lib/query mp (lib.metadata/table mp (meta/id :products)))
(lib/join (-> (lib/join-clause (lib.metadata/table mp (meta/id :reviews))
[(lib/=
(lib.metadata/field mp (meta/id :products :id))
(lib.metadata/field mp (meta/id :reviews :product-id)))])
(lib/with-join-fields :all)))
lib/->legacy-MBQL)
:database-id (meta/id)
:name "Products+Reviews"
:type :model}]})
mp (lib.tu/mock-metadata-provider
mp
{:cards [{:id 2
:dataset-query
(binding [lib.metadata.calculation/*display-name-style* :long]
(as-> (lib/query mp (lib.metadata/card mp 1)) $q
(lib/aggregate $q (lib/sum (->> $q
lib/available-aggregation-operators
(m/find-first (comp #{:sum} :short))
:columns
(m/find-first (comp #{"Price"} :display-name)))))
(lib/breakout $q (-> (m/find-first (comp #{"Reviews → Created At"} :display-name)
(lib/breakoutable-columns $q))
(lib/with-temporal-bucket :month)))
(lib/->legacy-MBQL $q)))
:database-id (meta/id)
:name "Products+Reviews Summary"
:type :model}]})
question (binding [lib.metadata.calculation/*display-name-style* :long]
(as-> (lib/query mp (lib.metadata/card mp 1)) $q
(lib/breakout $q (-> (m/find-first (comp #{"Reviews → Created At"} :display-name)
(lib/breakoutable-columns $q))
(lib/with-temporal-bucket :month)))
(lib/aggregate $q (lib/avg (->> $q
lib/available-aggregation-operators
(m/find-first (comp #{:avg} :short))
:columns
(m/find-first (comp #{"Rating"} :display-name)))))
(lib/append-stage $q)
(letfn [(find-col [query display-name]
(or (m/find-first #(= (:display-name %) display-name)
(lib/breakoutable-columns query))
(throw (ex-info "Failed to find column with display name"
{:display-name display-name
:found (map :display-name (lib/breakoutable-columns query))}))))]
(lib/join $q (-> (lib/join-clause (lib.metadata/card mp 2)
[(lib/=
(lib/with-temporal-bucket (find-col $q "Reviews → Created At: Month")
:month)
(lib/with-temporal-bucket (find-col
(lib/query mp (lib.metadata/card mp 2))
"Reviews → Created At: Month")
:month))])
(lib/with-join-fields :all))))))]
(qp.store/with-metadata-provider mp
(driver/with-driver :h2
(let [preprocessed (-> question qp.preprocess/preprocess)
expected (add/add-alias-info preprocessed)]
(testing ":source-query -> :source-query -> :joins"
(is (=? [{:alias "Reviews"
:condition [:=
[:field (meta/id :products :id)
{::add/source-alias "ID"}]
[:field (meta/id :reviews :product-id)
{:join-alias "Reviews", ::add/source-alias "PRODUCT_ID"}]]}]
(-> expected :query :source-query :source-query :joins))))
(testing ":source-query -> :source-query"
;; we should be using `Reviews__` here for names
(is (=? {:fields [[:field (meta/id :products :id)
{::add/source-alias "ID", ::add/desired-alias "ID"}]
[:field (meta/id :products :ean)
{::add/source-alias "EAN", ::add/desired-alias "EAN"}]
[:field (meta/id :products :title)
{::add/source-alias "TITLE", ::add/desired-alias "TITLE"}]
[:field (meta/id :products :category)
{::add/source-alias "CATEGORY", ::add/desired-alias "CATEGORY"}]
[:field (meta/id :products :vendor)
{::add/source-alias "VENDOR", ::add/desired-alias "VENDOR"}]
[:field (meta/id :products :price)
{::add/source-alias "PRICE", ::add/desired-alias "PRICE"}]
[:field (meta/id :products :rating)
{::add/source-alias "RATING", ::add/desired-alias "RATING"}]
[:field (meta/id :products :created-at)
{::add/source-alias "CREATED_AT", ::add/desired-alias "CREATED_AT"}]
[:field (meta/id :reviews :id)
{:join-alias "Reviews"
::add/source-alias "ID", ::add/desired-alias "Reviews__ID"}]
[:field (meta/id :reviews :product-id)
{:join-alias "Reviews", ::add/source-alias "PRODUCT_ID", ::add/desired-alias "Reviews__PRODUCT_ID"}]
[:field (meta/id :reviews :reviewer)
{:join-alias "Reviews", ::add/source-alias "REVIEWER", ::add/desired-alias "Reviews__REVIEWER"}]
[:field (meta/id :reviews :rating)
{:join-alias "Reviews", ::add/source-alias "RATING", ::add/desired-alias "Reviews__RATING"}]
[:field (meta/id :reviews :body)
{:join-alias "Reviews", ::add/source-alias "BODY", ::add/desired-alias "Reviews__BODY"}]
[:field (meta/id :reviews :created-at)
{:join-alias "Reviews", ::add/source-alias "CREATED_AT", ::add/desired-alias "Reviews__CREATED_AT"}]]}
(-> expected :query :source-query :source-query (dissoc :joins)))))
(testing ":source-query"
;; we should be using `Reviews__` here for names
(is (=? {:source-query/model? true
:breakout [[:field
"Reviews__CREATED_AT"
{::add/source-alias "Reviews__CREATED_AT", ::add/desired-alias "Reviews__CREATED_AT"}]]
:aggregation [[:aggregation-options
[:avg [:field "RATING" {::add/source-alias "RATING"}]]
{:name "avg", ::add/source-alias "avg", ::add/desired-alias "avg"}]]
:order-by [[:asc
[:field
"Reviews__CREATED_AT"
{::add/source-alias "Reviews__CREATED_AT", ::add/desired-alias "Reviews__CREATED_AT"}]]]}
(-> expected :query :source-query (dissoc :source-query)))))))))))
;;; adapted from [[metabase.query-processor-test.uuid-test/joined-uuid-query-test]]
(deftest ^:parallel resolve-field-missing-join-alias-test
(testing "should resolve broken refs missing join-alias correctly"