From decc8b7d5733944e5e11e8822e88f9b6ccdd9a4c Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Wed, 15 Mar 2023 18:51:08 +0200 Subject: [PATCH] Fix quick filter drill with nulls (#29190) --- .../29082-quick-filter-null.cy.spec.js | 47 ++++++++ .../queries/drills/quick-filter-drill.js | 110 +++++++++--------- .../components/drill/QuickFilterDrill.tsx | 6 +- .../drill/QuickFilterDrill.unit.spec.js | 84 +++++++++---- 4 files changed, 169 insertions(+), 78 deletions(-) create mode 100644 e2e/test/scenarios/question/reproductions/29082-quick-filter-null.cy.spec.js diff --git a/e2e/test/scenarios/question/reproductions/29082-quick-filter-null.cy.spec.js b/e2e/test/scenarios/question/reproductions/29082-quick-filter-null.cy.spec.js new file mode 100644 index 00000000000..2e0d8f89625 --- /dev/null +++ b/e2e/test/scenarios/question/reproductions/29082-quick-filter-null.cy.spec.js @@ -0,0 +1,47 @@ +import { popover, restore, visitQuestionAdhoc } from "e2e/support/helpers"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE; + +const questionDetails = { + name: "22788", + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + filter: ["=", ["field", ORDERS.USER_ID, null], 1], + }, + }, +}; + +describe("issue 29082", () => { + beforeEach(() => { + restore(); + cy.signInAsNormalUser(); + cy.intercept("POST", "/api/dataset").as("dataset"); + }); + + it("should handle nulls in quick filters (metabase#29082)", () => { + visitQuestionAdhoc(questionDetails); + cy.wait("@dataset"); + cy.findByText("Showing 11 rows").should("exist"); + + cy.get(".TableInteractive-emptyCell").first().click(); + popover().within(() => cy.findByText("=").click()); + cy.wait("@dataset"); + cy.findByText("Showing 8 rows").should("exist"); + cy.findByText("Discount is empty").should("exist"); + + cy.findByText("Discount is empty").within(() => cy.icon("close").click()); + cy.wait("@dataset"); + cy.findByText("Showing 11 rows").should("exist"); + + cy.get(".TableInteractive-emptyCell").first().click(); + popover().within(() => cy.findByText("≠").click()); + cy.wait("@dataset"); + cy.findByText("Showing 3 rows").should("exist"); + cy.findByText("Discount is not empty").should("exist"); + }); +}); diff --git a/frontend/src/metabase-lib/queries/drills/quick-filter-drill.js b/frontend/src/metabase-lib/queries/drills/quick-filter-drill.js index 4f02affe2ff..8505973cbbf 100644 --- a/frontend/src/metabase-lib/queries/drills/quick-filter-drill.js +++ b/frontend/src/metabase-lib/queries/drills/quick-filter-drill.js @@ -1,12 +1,13 @@ import { isa, - isTypeFK, - isTypePK, isDate, isNumeric, + isTypeFK, + isTypePK, } from "metabase-lib/types/utils/isa"; import { TYPE } from "metabase-lib/types/constants"; import { isLocalField } from "metabase-lib/queries/utils"; +import { fieldRefForColumn } from "metabase-lib/queries/utils/dataset"; const INVALID_TYPES = [TYPE.Structured]; @@ -22,73 +23,78 @@ export function quickFilterDrill({ question, clicked }) { return null; } - const { column } = clicked; + const { column, value } = clicked; if (isTypePK(column.semantic_type) || isTypeFK(column.semantic_type)) { return null; } - const operators = getOperatorsForColumn(column); + const operators = getOperatorsForColumn(column, value); return { operators }; } -export function quickFilterDrillQuestion({ question, clicked, operator }) { - const { column, value } = clicked; +export function quickFilterDrillQuestion({ question, clicked, filter }) { + const { column } = clicked; - if (isLocalField(column.field_ref)) { - return question.filter(operator, column, value); + if (isLocalColumn(column)) { + return question.query().filter(filter).question(); + } else { + /** + * For aggregated and custom columns + * with field refs like ["aggregation", 0], + * we need to nest the query as filters like ["=", ["aggregation", 0], value] won't work + * + * So the query like + * { + * aggregations: [["count"]] + * source-table: 2, + * } + * + * Becomes + * { + * source-query: { + * aggregations: [["count"]] + * source-table: 2, + * }, + * filter: ["=", [ "field", "count", {"base-type": "type/BigInteger"} ], value] + * } + */ + return question.query().nest().filter(filter).question(); } - - /** - * For aggregated and custom columns - * with field refs like ["aggregation", 0], - * we need to nest the query as filters like ["=", ["aggregation", 0], value] won't work - * - * So the query like - * { - * aggregations: [["count"]] - * source-table: 2, - * } - * - * Becomes - * { - * source-query: { - * aggregations: [["count"]] - * source-table: 2, - * }, - * filter: ["=", [ "field", "count", {"base-type": "type/BigInteger"} ], value] - * } - */ - - const nestedQuestion = question.query().nest().question(); - - return nestedQuestion.filter( - operator, - { - ...column, - field_ref: getFieldLiteralFromColumn(column), - }, - value, - ); } -function getOperatorsForColumn(column) { - if (isNumeric(column) || isDate(column)) { +function getOperatorsForColumn(column, value) { + const fieldRef = getColumnFieldRef(column); + + if (INVALID_TYPES.some(type => isa(column.base_type, type))) { + return []; + } else if (value == null) { return [ - { name: "<", operator: "<" }, - { name: ">", operator: ">" }, - { name: "=", operator: "=" }, - { name: "≠", operator: "!=" }, + { name: "=", filter: ["is-null", fieldRef] }, + { name: "≠", filter: ["not-null", fieldRef] }, ]; - } else if (!INVALID_TYPES.some(type => isa(column.base_type, type))) { + } else if (isNumeric(column) || isDate(column)) { return [ - { name: "=", operator: "=" }, - { name: "≠", operator: "!=" }, + { name: "<", filter: ["<", fieldRef, value] }, + { name: ">", filter: [">", fieldRef, value] }, + { name: "=", filter: ["=", fieldRef, value] }, + { name: "≠", filter: ["!=", fieldRef, value] }, ]; } else { - return []; + return [ + { name: "=", filter: ["=", fieldRef, value] }, + { name: "≠", filter: ["!=", fieldRef, value] }, + ]; } } -function getFieldLiteralFromColumn(column) { - return ["field", column.name, { "base-type": column.base_type }]; +function isLocalColumn(column) { + return isLocalField(column.field_ref); +} + +function getColumnFieldRef(column) { + if (isLocalColumn(column)) { + return fieldRefForColumn(column); + } else { + return ["field", column.name, { "base-type": column.base_type }]; + } } diff --git a/frontend/src/metabase/modes/components/drill/QuickFilterDrill.tsx b/frontend/src/metabase/modes/components/drill/QuickFilterDrill.tsx index 1f4053c17f4..2a04793081a 100644 --- a/frontend/src/metabase/modes/components/drill/QuickFilterDrill.tsx +++ b/frontend/src/metabase/modes/components/drill/QuickFilterDrill.tsx @@ -11,12 +11,12 @@ const QuickFilterDrill: Drill = ({ question, clicked }) => { return []; } - return drill.operators.map(({ name, operator }) => ({ - name: operator, + return drill.operators.map(({ name, filter }) => ({ + name, title: <span className="h2">{name}</span>, section: "filter", buttonType: "token-filter", - question: () => quickFilterDrillQuestion({ question, clicked, operator }), + question: () => quickFilterDrillQuestion({ question, clicked, filter }), })); }; diff --git a/frontend/test/metabase/modes/components/drill/QuickFilterDrill.unit.spec.js b/frontend/test/metabase/modes/components/drill/QuickFilterDrill.unit.spec.js index 94a01e4db93..026c975c638 100644 --- a/frontend/test/metabase/modes/components/drill/QuickFilterDrill.unit.spec.js +++ b/frontend/test/metabase/modes/components/drill/QuickFilterDrill.unit.spec.js @@ -8,8 +8,22 @@ import { } from "__support__/sample_database_fixture"; import Question from "metabase-lib/Question"; -const NUMBER_AND_DATE_FILTERS = ["<", ">", "=", "!="]; -const OTHER_FILTERS = ["=", "!="]; +const NUMBER_AND_DATE_FILTERS = [ + { name: "<", operator: "<" }, + { name: ">", operator: ">" }, + { name: "=", operator: "=" }, + { name: "≠", operator: "!=" }, +]; + +const NULL_FILTERS = [ + { name: "=", operator: "is-null" }, + { name: "≠", operator: "not-null" }, +]; + +const OTHER_FILTERS = [ + { name: "=", operator: "=" }, + { name: "≠", operator: "!=" }, +]; const DEFAULT_NUMERIC_CELL_VALUE = 42; @@ -111,14 +125,14 @@ describe("QuickFilterDrill", () => { const { actions } = setup({ column: clickedField.column() }); it("should return correct filters", () => { - const filters = NUMBER_AND_DATE_FILTERS.map(operator => ({ - name: operator, + const filters = NUMBER_AND_DATE_FILTERS.map(({ name }) => ({ + name, })); expect(actions).toMatchObject(filters); }); actions.forEach((action, i) => { - const operator = NUMBER_AND_DATE_FILTERS[i]; + const { operator } = NUMBER_AND_DATE_FILTERS[i]; it(`should correctly apply "${operator}" filter`, () => { const question = action.question(); expect(question.datasetQuery().query).toEqual({ @@ -141,14 +155,14 @@ describe("QuickFilterDrill", () => { }); it("should return correct filters", () => { - const filters = NUMBER_AND_DATE_FILTERS.map(operator => ({ - name: operator, + const filters = NUMBER_AND_DATE_FILTERS.map(({ name }) => ({ + name, })); expect(actions).toMatchObject(filters); }); actions.forEach((action, i) => { - const operator = NUMBER_AND_DATE_FILTERS[i]; + const { operator } = NUMBER_AND_DATE_FILTERS[i]; it(`should correctly apply "${operator}" filter`, () => { const question = action.question(); expect(question.datasetQuery().query).toEqual({ @@ -173,14 +187,14 @@ describe("QuickFilterDrill", () => { }); it("should return correct filters", () => { - const filters = NUMBER_AND_DATE_FILTERS.map(operator => ({ - name: operator, + const filters = NUMBER_AND_DATE_FILTERS.map(({ name }) => ({ + name, })); expect(actions).toMatchObject(filters); }); actions.forEach((action, i) => { - const operator = NUMBER_AND_DATE_FILTERS[i]; + const { operator } = NUMBER_AND_DATE_FILTERS[i]; it(`should correctly apply "${operator}" filter`, () => { const question = action.question(); expect(question.datasetQuery().query).toEqual({ @@ -213,14 +227,14 @@ describe("QuickFilterDrill", () => { }); it("should return correct filters", () => { - const filters = NUMBER_AND_DATE_FILTERS.map(operator => ({ - name: operator, + const filters = NUMBER_AND_DATE_FILTERS.map(({ name }) => ({ + name, })); expect(actions).toMatchObject(filters); }); actions.forEach((action, i) => { - const operator = NUMBER_AND_DATE_FILTERS[i]; + const { operator } = NUMBER_AND_DATE_FILTERS[i]; it(`should correctly apply "${operator}" filter`, () => { const question = action.question(); expect(question.datasetQuery().query).toEqual({ @@ -232,6 +246,30 @@ describe("QuickFilterDrill", () => { }); }); + describe("numeric cells with null values", () => { + const clickedField = ORDERS.TOTAL; + const { actions } = setup({ column: clickedField.column(), value: null }); + + it("should return correct filters", () => { + const filters = NULL_FILTERS.map(({ name }) => ({ + name, + })); + expect(actions).toMatchObject(filters); + }); + + actions.forEach((action, i) => { + const { operator } = NULL_FILTERS[i]; + it(`should correctly apply "${operator}" filter`, () => { + const question = action.question(); + expect(question.datasetQuery().query).toEqual({ + "source-table": ORDERS.id, + filter: [operator, clickedField.reference()], + }); + expect(question.display()).toBe("table"); + }); + }); + }); + describe("date-time cells", () => { const CELL_VALUE = new Date().toISOString(); const { actions } = setup({ @@ -240,14 +278,14 @@ describe("QuickFilterDrill", () => { }); it("should return correct filters", () => { - const filters = NUMBER_AND_DATE_FILTERS.map(operator => ({ - name: operator, + const filters = NUMBER_AND_DATE_FILTERS.map(({ name }) => ({ + name, })); expect(actions).toMatchObject(filters); }); actions.forEach((action, i) => { - const operator = NUMBER_AND_DATE_FILTERS[i]; + const { operator } = NUMBER_AND_DATE_FILTERS[i]; it(`should correctly apply "${operator}" filter`, () => { const question = action.question(); expect(question.datasetQuery().query).toEqual({ @@ -268,14 +306,14 @@ describe("QuickFilterDrill", () => { }); it("should return correct filters", () => { - const filters = OTHER_FILTERS.map(operator => ({ - name: operator, + const filters = OTHER_FILTERS.map(({ name }) => ({ + name, })); expect(actions).toMatchObject(filters); }); actions.forEach((action, i) => { - const operator = OTHER_FILTERS[i]; + const { operator } = OTHER_FILTERS[i]; it(`should correctly apply "${operator}" filter`, () => { const question = action.question(); expect(question.datasetQuery().query).toEqual({ @@ -294,14 +332,14 @@ describe("QuickFilterDrill", () => { }); it("should return correct filters", () => { - const filters = OTHER_FILTERS.map(operator => ({ - name: operator, + const filters = OTHER_FILTERS.map(({ name }) => ({ + name, })); expect(actions).toMatchObject(filters); }); actions.forEach((action, i) => { - const operator = OTHER_FILTERS[i]; + const { operator } = OTHER_FILTERS[i]; it(`should correctly apply "${operator}" filter`, () => { const question = action.question(); expect(question.datasetQuery().query).toEqual({ -- GitLab