From b4a0d3cf8d916f5395b245e32c3edbb1e334d729 Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Thu, 29 Sep 2022 11:10:51 +0300 Subject: [PATCH] Handle metric filters in drilldowns (#25573) --- frontend/src/metabase-lib/lib/Question.ts | 45 ++++++++-- .../src/metabase-lib/lib/metadata/Metadata.ts | 3 +- .../src/metabase-lib/lib/metadata/Metric.ts | 12 ++- .../lib/queries/structured/Aggregation.ts | 59 +++++++++++- .../drill/UnderlyingRecordsDrill.jsx | 23 +---- frontend/src/metabase/modes/lib/actions.js | 24 ----- .../__support__/sample_database_fixture.js | 1 + .../__support__/sample_database_fixture.json | 31 +++++++ .../metabase-lib/lib/Question.unit.spec.js | 89 ++++++++++++++++--- .../6010-metric-filter-drill.cy.spec.js | 54 +++++++++++ 10 files changed, 268 insertions(+), 73 deletions(-) create mode 100644 frontend/test/metabase/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js diff --git a/frontend/src/metabase-lib/lib/Question.ts b/frontend/src/metabase-lib/lib/Question.ts index 7fedba9b2fe..dec1762ac7b 100644 --- a/frontend/src/metabase-lib/lib/Question.ts +++ b/frontend/src/metabase-lib/lib/Question.ts @@ -49,10 +49,9 @@ import { aggregate, breakout, distribution, - drillUnderlyingRecords, + drillFilter, filter, pivot, - toUnderlyingRecords, } from "metabase/modes/lib/actions"; import { DashboardApi, @@ -67,10 +66,13 @@ import { } from "metabase-types/types/Parameter"; import { Card as CardObject, DatasetQuery } from "metabase-types/types/Card"; import { VisualizationSettings } from "metabase-types/api/card"; -import { Dataset, Value } from "metabase-types/types/Dataset"; +import { Column, Dataset, Value } from "metabase-types/types/Dataset"; import { TableId } from "metabase-types/types/Table"; import { DatabaseId } from "metabase-types/types/Database"; -import { ClickObject } from "metabase-types/types/Visualization"; +import { + ClickObject, + DimensionValue, +} from "metabase-types/types/Visualization"; import { DependentMetadataItem } from "metabase-types/types/Query"; import { utf8_to_b64url } from "metabase/lib/encoding"; import { CollectionId } from "metabase-types/api"; @@ -536,12 +538,41 @@ class QuestionInner { return pivot(this, breakouts, dimensions) || this; } - drillUnderlyingRecords(dimensions): Question { - return drillUnderlyingRecords(this, dimensions) || this; + drillUnderlyingRecords(values: DimensionValue[], column?: Column): Question { + let query = this.query(); + if (!(query instanceof StructuredQuery)) { + return this; + } + + query = values.reduce( + (query, { value, column }) => drillFilter(query, value, column), + query, + ); + + const dimension = column && query.parseFieldReference(column.field_ref); + if (dimension instanceof AggregationDimension) { + const aggregation = dimension.aggregation(); + const filters = aggregation ? aggregation.filters() : []; + query = filters.reduce((query, filter) => query.filter(filter), query); + } + + return query.question().toUnderlyingRecords(); } toUnderlyingRecords(): Question { - return toUnderlyingRecords(this) || this; + const query = this.query(); + if (!(query instanceof StructuredQuery)) { + return this; + } + + return query + .clearAggregations() + .clearBreakouts() + .clearSort() + .clearLimit() + .clearFields() + .question() + .setDisplay("table"); } toUnderlyingData(): Question { diff --git a/frontend/src/metabase-lib/lib/metadata/Metadata.ts b/frontend/src/metabase-lib/lib/metadata/Metadata.ts index 42290d1a26a..26c030dccf1 100644 --- a/frontend/src/metabase-lib/lib/metadata/Metadata.ts +++ b/frontend/src/metabase-lib/lib/metadata/Metadata.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck import _ from "underscore"; +import Metric from "metabase-lib/lib/metadata/Metric"; import type Question from "../Question"; import Base from "./Base"; import type Database from "./Database"; @@ -75,7 +76,7 @@ export default class Metadata extends Base { * @param {MetricId} metricId * @returns {?Metric} */ - metric(metricId) { + metric(metricId): Metric | null { return (metricId != null && this.metrics[metricId]) || null; } diff --git a/frontend/src/metabase-lib/lib/metadata/Metric.ts b/frontend/src/metabase-lib/lib/metadata/Metric.ts index dec163eabf7..db44bd3810a 100644 --- a/frontend/src/metabase-lib/lib/metadata/Metric.ts +++ b/frontend/src/metabase-lib/lib/metadata/Metric.ts @@ -1,5 +1,6 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck +import Filter from "metabase-lib/lib/queries/structured/Filter"; import Base from "./Base"; /** * @typedef { import("./metadata").Aggregation } Aggregation @@ -28,13 +29,16 @@ export default class Metric extends Base { : null; } + /** Underlying filter clauses for this metric */ + filters(): Filter[] { + const query = this.definitionQuery(); + return query ? query.filters() : []; + } + /** Underlying aggregation clause for this metric */ aggregation() { const query = this.definitionQuery(); - - if (query) { - return query.aggregations()[0]; - } + return query?.aggregations()[0]; } /** Column name when this metric is used in a query */ diff --git a/frontend/src/metabase-lib/lib/queries/structured/Aggregation.ts b/frontend/src/metabase-lib/lib/queries/structured/Aggregation.ts index ec8275a3846..5b367b89ac2 100644 --- a/frontend/src/metabase-lib/lib/queries/structured/Aggregation.ts +++ b/frontend/src/metabase-lib/lib/queries/structured/Aggregation.ts @@ -7,6 +7,8 @@ import { Aggregation as AggregationObject } from "metabase-types/types/Query"; import { AggregationOperator } from "metabase-types/types/Metadata"; import { MetricId } from "metabase-types/types/Metric"; import { FieldId } from "metabase-types/types/Field"; +import Filter from "metabase-lib/lib/queries/structured/Filter"; +import Metric from "metabase-lib/lib/metadata/Metric"; import StructuredQuery from "../StructuredQuery"; import Dimension, { AggregationDimension } from "../../Dimension"; import MBQLClause from "./MBQLClause"; @@ -206,10 +208,18 @@ export default class Aggregation extends MBQLClause { * Get the operator from a standard aggregation clause * Returns `null` if the clause isn't a "standard" metric */ - operatorName(): string | null | undefined { + operatorName(): string | null { if (this.isStandard()) { return this[0]; } + + return null; + } + + expressionName(): string | null { + if (this.isCustom()) { + return this[0]; + } } /** @@ -238,16 +248,20 @@ export default class Aggregation extends MBQLClause { * Get metricId from a metric aggregation clause * Returns `null` if the clause doesn't represent a metric */ - metricId(): MetricId | null | undefined { + metricId(): MetricId | null { if (this.isMetric()) { return this[1]; } + + return null; } - metric() { + metric(): Metric | null { if (this.isMetric()) { return this.metadata().metric(this.metricId()); } + + return null; } // OPTIONS @@ -266,7 +280,7 @@ export default class Aggregation extends MBQLClause { /** * Returns the aggregation without "aggregation-options" clause, if any */ - aggregation() { + aggregation(): Aggregation { if (this.hasOptions()) { return new Aggregation(this[1], this._index, this._query); } else { @@ -274,6 +288,43 @@ export default class Aggregation extends MBQLClause { } } + filters(): Filter[] { + if (this.isCustom()) { + const filter = this.customFilter(); + return filter ? [filter] : []; + } + + if (this.isMetric()) { + const filters = this.metricFilters(); + return filters ?? []; + } + + return []; + } + + customFilter(): Filter | null { + if (this.isCustom()) { + switch (this.expressionName()) { + case "share": + case "count-where": + return new Filter(this[1], null, this.query()); + case "sum-where": + return new Filter(this[2], null, this.query()); + } + } + + return null; + } + + metricFilters(): Filter[] | null { + if (this.isMetric()) { + const metric = this.metric(); + return metric?.filters().map(filter => filter.setQuery(this.query())); + } + + return null; + } + // MISC aggregationDimension() { return new AggregationDimension( diff --git a/frontend/src/metabase/modes/components/drill/UnderlyingRecordsDrill.jsx b/frontend/src/metabase/modes/components/drill/UnderlyingRecordsDrill.jsx index e43ccac17b1..675f264a6b0 100644 --- a/frontend/src/metabase/modes/components/drill/UnderlyingRecordsDrill.jsx +++ b/frontend/src/metabase/modes/components/drill/UnderlyingRecordsDrill.jsx @@ -1,8 +1,6 @@ import { ngettext, msgid } from "ttag"; import { inflect } from "metabase/lib/formatting"; -import { AggregationDimension } from "metabase-lib/lib/Dimension"; - export default ({ question, clicked }) => { // removes post-aggregation filter stage clicked = clicked && question.topLevelClicked(clicked); @@ -21,20 +19,6 @@ export default ({ question, clicked }) => { // the metric value should be the number of rows that will be displayed const count = typeof clicked.value === "number" ? clicked.value : 2; - // special case for aggregations that include a filter, such as share, count-where, and sum-where - let extraFilter = null; - const dimension = - clicked.column && query.parseFieldReference(clicked.column.field_ref); - if (dimension instanceof AggregationDimension) { - const aggregation = dimension.aggregation(); - extraFilter = - aggregation[0] === "count-where" || aggregation[0] === "share" - ? aggregation[1] - : aggregation[0] === "sum-where" - ? aggregation[2] - : null; - } - const recordName = query.table() && query.table().displayName(); const inflectedTableName = recordName ? inflect(recordName, count) @@ -51,12 +35,7 @@ export default ({ question, clicked }) => { count, ), question: () => { - const q = question.drillUnderlyingRecords(dimensions); - if (extraFilter) { - return q.query().filter(extraFilter).question(); - } else { - return q; - } + return question.drillUnderlyingRecords(dimensions, clicked.column); }, }, ]; diff --git a/frontend/src/metabase/modes/lib/actions.js b/frontend/src/metabase/modes/lib/actions.js index 1d676256108..af276fe197a 100644 --- a/frontend/src/metabase/modes/lib/actions.js +++ b/frontend/src/metabase/modes/lib/actions.js @@ -83,30 +83,6 @@ export function distribution(question, column) { } } -export function toUnderlyingRecords(question) { - const query = question.query(); - if (query instanceof StructuredQuery) { - return query - .clearAggregations() - .clearBreakouts() - .clearSort() - .clearLimit() - .clearFields() - .question() - .setDisplay("table"); - } -} - -export function drillUnderlyingRecords(question, dimensions) { - let query = question.query(); - if (query instanceof StructuredQuery) { - for (const dimension of dimensions) { - query = drillFilter(query, dimension.value, dimension.column); - } - return toUnderlyingRecords(query.question()); - } -} - // STRUCTURED QUERY UTILITIES const fieldRefWithTemporalUnit = (mbqlClause, unit) => { diff --git a/frontend/test/__support__/sample_database_fixture.js b/frontend/test/__support__/sample_database_fixture.js index ff96c276983..442dc28fbdd 100644 --- a/frontend/test/__support__/sample_database_fixture.js +++ b/frontend/test/__support__/sample_database_fixture.js @@ -82,6 +82,7 @@ export function makeMetadata(metadata) { }, metrics: { 1: { name: "metric" }, + 2: { name: "metric" }, }, segments: { 1: { name: "segment" }, diff --git a/frontend/test/__support__/sample_database_fixture.json b/frontend/test/__support__/sample_database_fixture.json index 60065705f31..b59d59aaf53 100644 --- a/frontend/test/__support__/sample_database_fixture.json +++ b/frontend/test/__support__/sample_database_fixture.json @@ -57,6 +57,37 @@ "how_is_this_calculated": null, "created_at": "2017-06-14T23:32:12.267Z", "points_of_interest": null + }, + "2": { + "description": "Because we want to know the total I ugess", + "table_id": 1, + "definition": { + "filter": [">", 6, 20], + "aggregation": [["sum", ["field", 6, null]]], + "source-table": 1 + }, + "creator": { + "email": "sameer@metabase.com", + "first_name": "Sameer", + "last_login": "2017-06-14T23:23:59.582Z", + "is_qbnewb": true, + "is_superuser": true, + "id": 1, + "last_name": "Al-Sakran", + "date_joined": "2017-06-14T23:23:59.409Z", + "common_name": "Sameer Al-Sakran" + }, + "database_id": 1, + "show_in_getting_started": false, + "name": "Total Order Value", + "archived": false, + "caveats": null, + "creator_id": 1, + "updated_at": "2017-06-14T23:32:12.266Z", + "id": 2, + "how_is_this_calculated": null, + "created_at": "2017-06-14T23:32:12.267Z", + "points_of_interest": null } }, "segments": { diff --git a/frontend/test/metabase-lib/lib/Question.unit.spec.js b/frontend/test/metabase-lib/lib/Question.unit.spec.js index f87a77d1c05..15e8b3c25fe 100644 --- a/frontend/test/metabase-lib/lib/Question.unit.spec.js +++ b/frontend/test/metabase-lib/lib/Question.unit.spec.js @@ -56,6 +56,36 @@ const orders_count_card = { }, }; +const orders_count_where_card = { + id: 2, + name: "# orders data", + display: "table", + visualization_settings: {}, + dataset_query: { + type: "query", + database: SAMPLE_DATABASE.id, + query: { + "source-table": ORDERS.id, + aggregation: [["count-where", [">", ORDERS.TOTAL.id, 50]]], + }, + }, +}; + +const orders_metric_filter_card = { + id: 2, + name: "# orders data", + display: "table", + visualization_settings: {}, + dataset_query: { + type: "query", + database: SAMPLE_DATABASE.id, + query: { + "source-table": ORDERS.id, + aggregation: [["metric", 2]], + }, + }, +}; + const native_orders_count_card = { id: 3, name: "# orders data", @@ -531,20 +561,13 @@ describe("Question", () => { }); describe("drillUnderlyingRecords(...)", () => { - const ordersCountQuestion = new Question( - orders_count_by_id_card, - metadata, - ); - - // ??? - it("applies a filter to a given filterspec", () => { + it("applies a filter to a given query", () => { + const question = new Question(orders_count_by_id_card, metadata); const dimensions = [{ value: 1, column: ORDERS.ID.column() }]; - const drilledQuestion = - ordersCountQuestion.drillUnderlyingRecords(dimensions); - expect(drilledQuestion.canRun()).toBe(true); + const newQuestion = question.drillUnderlyingRecords(dimensions); - expect(drilledQuestion._card.dataset_query).toEqual({ + expect(newQuestion._card.dataset_query).toEqual({ type: "query", database: SAMPLE_DATABASE.id, query: { @@ -553,6 +576,50 @@ describe("Question", () => { }, }); }); + + it("applies a filter from an aggregation to a given query", () => { + const question = new Question(orders_count_where_card, metadata); + const dimensions = [{ value: 1, column: ORDERS.ID.column() }]; + const column = { field_ref: ["aggregation", 0] }; + + const newQuestion = question.drillUnderlyingRecords(dimensions, column); + + expect(newQuestion.canRun()).toBe(true); + expect(newQuestion._card.dataset_query).toEqual({ + type: "query", + database: SAMPLE_DATABASE.id, + query: { + "source-table": ORDERS.id, + filter: [ + "and", + ["=", ["field", ORDERS.ID.id, null], 1], + [">", ORDERS.TOTAL.id, 50], + ], + }, + }); + }); + + it("applies a filter from a metric to a given query", () => { + const question = new Question(orders_metric_filter_card, metadata); + const dimensions = [{ value: 1, column: ORDERS.ID.column() }]; + const column = { field_ref: ["aggregation", 0] }; + + const newQuestion = question.drillUnderlyingRecords(dimensions, column); + + expect(newQuestion.canRun()).toBe(true); + expect(newQuestion._card.dataset_query).toEqual({ + type: "query", + database: SAMPLE_DATABASE.id, + query: { + "source-table": ORDERS.id, + filter: [ + "and", + ["=", ["field", ORDERS.ID.id, null], 1], + [">", ORDERS.TOTAL.id, 20], + ], + }, + }); + }); }); describe("toUnderlyingRecords(...)", () => { diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js b/frontend/test/metabase/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js new file mode 100644 index 00000000000..0a49feb184a --- /dev/null +++ b/frontend/test/metabase/scenarios/visualizations/reproductions/6010-metric-filter-drill.cy.spec.js @@ -0,0 +1,54 @@ +import { restore, visitQuestion } from "__support__/e2e/helpers"; +import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; + +const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; + +describe("issue 6010", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + cy.intercept("POST", "/api/dataset").as("dataset"); + }); + + it("should apply the filter from a metric when drilling through (metabase#6010)", () => { + createMetric() + .then(({ body: { id } }) => createQuestion(id)) + .then(({ body: { id } }) => visitQuestion(id)); + + cy.get(".dot").eq(0).click({ force: true }); + cy.findByText(/View these Orders/).click(); + cy.wait("@dataset"); + + cy.findByText("Created At is January, 2018").should("be.visible"); + cy.findByText("Total is greater than 150").should("be.visible"); + }); +}); + +const createMetric = () => { + return cy.request("POST", "/api/metric", { + name: "Metric", + description: "Metric with a filter", + table_id: ORDERS_ID, + definition: { + "source-table": ORDERS_ID, + filter: [">", ORDERS.TOTAL, 150], + aggregation: [["count"]], + }, + }); +}; + +const createQuestion = metric_id => { + return cy.createQuestion({ + name: "Question", + display: "line", + query: { + "source-table": ORDERS_ID, + breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }]], + aggregation: [["metric", metric_id]], + }, + visualization_settings: { + "graph.dimensions": ["CREATED_AT"], + "graph.metrics": ["count"], + }, + }); +}; -- GitLab