From f596f0b00f3670d5624d23f7eafe0499c0cdc2ed Mon Sep 17 00:00:00 2001 From: Kamil Mielnik <kamil@kamilmielnik.com> Date: Mon, 13 May 2024 18:04:20 +0700 Subject: [PATCH] Cover `Offset()` in aggregations with e2e tests (#42455) * Add repro for 32323 * Fix offset not working in case * Make offset function return any * Add a repro for #42377 * Fix order of adjustments * Revert unrelated changes * Remove commented code * Revert unrelated changes * Refactor test * Type offset aggregation * Add a test for no order by or breakout clause * Add a basic test for breakout clause * Fix assertion * Remove problematic dependency * Fix uuids generation * Remove redundant limit * Add a test for multiple breakouts * Update test names * Extract OFFSET_SUM_TOTAL_AGGREGATION * Add a repro for metabase#42509 * Add a test for multiple aggregations and breakouts * Remove unused intercept * Move uuid utils to separate file - it wasn't possible to import the utils file in e2e tests without it * Make isUuid a type guard * Add tests for sorting * Tag the test * Add extra assertion to verify expression parsing * Add a complex scenario * Reverse isFirst logic --- e2e/test/scenarios/question/offset.cy.spec.ts | 380 +++++++++++++++++- frontend/src/metabase-types/api/query.ts | 9 +- .../widgets/CustomGeoJSONWidget.jsx | 2 +- frontend/src/metabase/dashboard/utils.ts | 5 +- frontend/src/metabase/lib/utils.ts | 30 -- frontend/src/metabase/lib/uuid.ts | 29 ++ .../TagEditorHelp/TagEditorHelp.tsx | 2 +- .../SmartScalarSettingsWidgets.tsx | 9 +- .../visualizations/SmartScalar/utils.ts | 2 +- 9 files changed, 413 insertions(+), 55 deletions(-) create mode 100644 frontend/src/metabase/lib/uuid.ts diff --git a/e2e/test/scenarios/question/offset.cy.spec.ts b/e2e/test/scenarios/question/offset.cy.spec.ts index f20370dc1e3..76ac383fc08 100644 --- a/e2e/test/scenarios/question/offset.cy.spec.ts +++ b/e2e/test/scenarios/question/offset.cy.spec.ts @@ -1,43 +1,395 @@ import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; -import type { StructuredQuestionDetails } from "e2e/support/helpers"; import { createQuestion, + echartsContainer, enterCustomColumnDetails, getNotebookStep, + modal, openNotebook, popover, restore, + startNewQuestion, + visitQuestion, + visualize, } from "e2e/support/helpers"; +import { uuid } from "metabase/lib/uuid"; +import type { + Aggregation, + Breakout, + FieldReference, + StructuredQuery, +} from "metabase-types/api"; -const { ORDERS_ID } = SAMPLE_DATABASE; +const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; + +const ORDERS_TOTAL_FIELD_REF: FieldReference = [ + "field", + ORDERS.TOTAL, + { "base-type": "type/Float" }, +]; + +const ORDERS_CREATED_AT_BREAKOUT: Breakout = [ + "field", + ORDERS.CREATED_AT, + { "base-type": "type/DateTime", "temporal-unit": "month" }, +]; + +const PRODUCTS_CATEGORY_BREAKOUT: Breakout = [ + "field", + PRODUCTS.CATEGORY, + { "base-type": "type/text", "source-field": ORDERS.PRODUCT_ID }, +]; + +const SUM_TOTAL_AGGREGATION: Aggregation = ["sum", ORDERS_TOTAL_FIELD_REF]; + +const OFFSET_SUM_TOTAL_AGGREGATION_NAME = "Offsetted sum of total"; + +const OFFSET_SUM_TOTAL_AGGREGATION: Aggregation = [ + "offset", + createOffsetOptions(OFFSET_SUM_TOTAL_AGGREGATION_NAME), + SUM_TOTAL_AGGREGATION, + -1, +]; describe("scenarios > question > offset", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); + cy.intercept("POST", "/api/card").as("saveQuestion"); + }); + + it("does not work without a breakout", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyQuestionError( + "Window function requires either breakouts or order by in the query", + ); + }); + + it("works with a single breakout", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT], + limit: 5, + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2022", ""], + ["May 2022", "52.76"], + ]); + }); + + it("works with a single breakout and sorting by breakout", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT], + limit: 5, + "order-by": [["desc", ORDERS_CREATED_AT_BREAKOUT]], + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2026", "45,683.68"], + ["March 2026", "47,403.97"], + ]); + }); + + it.skip("works with a single breakout and sorting by aggregation (metabase#42554)", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT], + limit: 5, + "order-by": [["desc", ["aggregation", 0]]], + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + /* TODO: assert actual values */ + // verifyTableContent([[]]); + }); + + it("works with multiple breakouts", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT, PRODUCTS_CATEGORY_BREAKOUT], + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2022", "", "", "", ""], + ["May 2022", "", "52.76", "", ""], + ["June 2022", "339.14", "203.57", "493.51", "229.5"], + ]); + }); + + it.skip("works with multiple breakouts and a limit (metabase#42509)", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT, PRODUCTS_CATEGORY_BREAKOUT], + limit: 5, + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2022", "", "", "", ""], + ["May 2022", "", "52.76", "", ""], + ["June 2022", "339.14", "203.57", "493.51", "229.5"], + ]); + }); + + it("works with multiple aggregations and breakouts", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [SUM_TOTAL_AGGREGATION, OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT, PRODUCTS_CATEGORY_BREAKOUT], + limit: 9, + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2022", "Gadget", "52.76", ""], + ["May 2022", "Doohickey", "339.14", ""], + ["May 2022", "Gadget", "203.57", "52.76"], + ["May 2022", "Gizmo", "493.51", ""], + ["May 2022", "Widget", "229.5", ""], + ["June 2022", "Doohickey", "482.56", "339.14"], + ["June 2022", "Gadget", "515.53", "203.57"], + ["June 2022", "Gizmo", "387.79", "493.51"], + ["June 2022", "Widget", "687.06", "229.5"], + ]); + }); + + it("works after saving a question (metabase#42323)", () => { + const breakoutName = "Created At"; + + startNewQuestion(); + popover().within(() => { + cy.findByText("Raw Data").click(); + cy.findByText("Orders").click(); + }); + addCustomAggregation({ + formula: "Offset(Sum([Total]), -1)", + name: OFFSET_SUM_TOTAL_AGGREGATION_NAME, + isFirst: true, + }); + addBreakout(breakoutName); + + visualize(); + verifyNoQuestionError(); + verifyLineChart({ + xAxis: breakoutName, + yAxis: OFFSET_SUM_TOTAL_AGGREGATION_NAME, + }); + + saveQuestion().then(({ response }) => { + visitQuestion(response?.body.id); + verifyNoQuestionError(); + verifyLineChart({ + xAxis: breakoutName, + yAxis: OFFSET_SUM_TOTAL_AGGREGATION_NAME, + }); + }); + }); + + it("works in a complex real-life scenario", () => { + const breakoutName = "Created At"; + const totalSalesName = "Total sales (this month)"; + const totalSalesLastMonthName = "Total sales (last month)"; + const percentSalesGrowthName = "Percent sales growth over last month"; + const rollingTotalName = "Rolling total of sales last 3 months"; + const rollingAverageName = "Rolling average of sales last 3 months"; + const legendItems = [ + totalSalesName, + totalSalesLastMonthName, + percentSalesGrowthName, + rollingTotalName, + rollingAverageName, + ]; + + startNewQuestion(); + popover().within(() => { + cy.findByText("Raw Data").click(); + cy.findByText("Orders").click(); + }); + addCustomAggregation({ + formula: "Sum([Total])", + name: totalSalesName, + isFirst: true, + }); + addCustomAggregation({ + formula: "Offset(Sum([Total]), -1)", + name: totalSalesLastMonthName, + }); + addCustomAggregation({ + formula: "Sum([Total]) / Offset(Sum([Total]), -1) - 1", + name: percentSalesGrowthName, + }); + addCustomAggregation({ + formula: + "Sum([Total]) + Offset(Sum([Total]), -1) + Offset(Sum([Total]), -2)", + name: rollingTotalName, + }); + addCustomAggregation({ + formula: + "(Sum([Total]) + Offset(Sum([Total]), -1) + Offset(Sum([Total]), -2)) / 3", + name: rollingAverageName, + }); + addBreakout(breakoutName); + + visualize(); + verifyNoQuestionError(); + verifyLineChart({ + xAxis: breakoutName, + legendItems, + }); + + saveQuestion().then(({ response }) => { + visitQuestion(response?.body.id); + verifyNoQuestionError(); + verifyLineChart({ + xAxis: breakoutName, + legendItems, + }); + }); }); it("should allow using OFFSET as a CASE argument (metabase#42377)", () => { const formula = "Sum(case([Total] > 0, Offset([Total], -1)))"; const name = "Aggregation"; - const questionDetails: StructuredQuestionDetails = { - query: { - "source-table": ORDERS_ID, - limit: 5, - }, + const query: StructuredQuery = { + "source-table": ORDERS_ID, }; - createQuestion(questionDetails, { visitQuestion: true }); - openNotebook(); + createQuestion({ query }, { visitQuestion: true }); + openNotebook(); cy.icon("sum").click(); - getNotebookStep("summarize") - .findByText("Pick the metric you want to see") - .click(); - popover().contains("Custom Expression").click(); - enterCustomColumnDetails({ formula, name }); + addCustomAggregation({ formula, name, isFirst: true }); + + cy.findAllByTestId("notebook-cell-item").contains(name).click(); + cy.findByTestId("expression-editor-textfield").should("contain", formula); cy.on("uncaught:exception", error => { expect(error.message.includes("Error normalizing")).not.to.be.true; }); }); }); + +function addCustomAggregation({ + formula, + name, + isFirst, +}: { + formula: string; + name: string; + isFirst?: boolean; +}) { + if (isFirst) { + getNotebookStep("summarize") + .findByText("Pick the metric you want to see") + .click(); + } else { + getNotebookStep("summarize").icon("add").click(); + } + + popover().contains("Custom Expression").click(); + enterCustomColumnDetails({ formula, name }); + popover().button("Done").click(); +} + +function addBreakout(name: string) { + getNotebookStep("summarize").findByText("Pick a column to group by").click(); + popover().findByText(name).click(); +} + +function saveQuestion() { + cy.button("Save").click(); + modal().button("Save").click(); + return cy.wait("@saveQuestion"); +} + +function verifyLineChart({ + xAxis, + yAxis, + legendItems, +}: { + xAxis: string; + yAxis?: string; + legendItems?: string[]; +}) { + echartsContainer().within(() => { + cy.findByText(xAxis).should("be.visible"); + + if (yAxis) { + cy.findByText(yAxis).should("be.visible"); + } + }); + + if (legendItems) { + for (const legendItem of legendItems) { + cy.findAllByTestId("legend-item").contains(legendItem).should("exist"); + } + } +} + +function verifyTableContent(rows: string[][]) { + const columnsCount = rows[0].length; + const pairs = rows.flatMap((row, rowIndex) => { + return row.map((text, cellIndex) => { + const index = rowIndex * columnsCount + cellIndex; + return { index, text }; + }); + }); + + for (const { index, text } of pairs) { + verifyTableCellContent(index, text); + } +} + +function verifyTableCellContent(index: number, text: string) { + cy.findAllByRole("gridcell").eq(index).should("have.text", text); +} + +function verifyQuestionError(error: string) { + cy.findByTestId("query-builder-main").within(() => { + cy.findByText("There was a problem with your question").should("exist"); + cy.findByText("Show error details").click(); + cy.findByText(error).should("exist"); + }); +} + +function verifyNoQuestionError() { + cy.findByTestId("query-builder-main").within(() => { + cy.findByText("There was a problem with your question").should("not.exist"); + cy.findByText("Show error details").should("not.exist"); + }); +} + +function createOffsetOptions(name = "offset") { + return { + "lib/uuid": uuid(), + name, + "display-name": name, + }; +} diff --git a/frontend/src/metabase-types/api/query.ts b/frontend/src/metabase-types/api/query.ts index d009da26dce..15ad7636331 100644 --- a/frontend/src/metabase-types/api/query.ts +++ b/frontend/src/metabase-types/api/query.ts @@ -155,6 +155,12 @@ type StdDevAgg = ["stddev", ConcreteFieldReference]; type SumAgg = ["sum", ConcreteFieldReference]; type MinAgg = ["min", ConcreteFieldReference]; type MaxAgg = ["max", ConcreteFieldReference]; +type OffsetAgg = [ + "offset", + { "lib/uuid": string; name: string; "display-name": string }, + Aggregation, + number, +]; type CommonAggregation = | CountAgg @@ -166,7 +172,8 @@ type CommonAggregation = | StdDevAgg | SumAgg | MinAgg - | MaxAgg; + | MaxAgg + | OffsetAgg; type MetricAgg = ["metric", MetricId]; diff --git a/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx b/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx index 3b0f107c125..926c295dc7d 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx @@ -12,7 +12,7 @@ import Select, { Option } from "metabase/core/components/Select"; import AdminS from "metabase/css/admin.module.css"; import ButtonsS from "metabase/css/components/buttons.module.css"; import CS from "metabase/css/core/index.css"; -import { uuid } from "metabase/lib/utils"; +import { uuid } from "metabase/lib/uuid"; import { SettingsApi, GeoJSONApi } from "metabase/services"; import LeafletChoropleth from "metabase/visualizations/components/LeafletChoropleth"; diff --git a/frontend/src/metabase/dashboard/utils.ts b/frontend/src/metabase/dashboard/utils.ts index 5d8b70c0c49..14c6856be4a 100644 --- a/frontend/src/metabase/dashboard/utils.ts +++ b/frontend/src/metabase/dashboard/utils.ts @@ -3,7 +3,8 @@ import _ from "underscore"; import { IS_EMBED_PREVIEW } from "metabase/lib/embed"; import { SERVER_ERROR_TYPES } from "metabase/lib/errors"; -import { isUUID, isJWT } from "metabase/lib/utils"; +import { isJWT } from "metabase/lib/utils"; +import { isUuid } from "metabase/lib/uuid"; import { getGenericErrorMessage, getPermissionErrorMessage, @@ -182,7 +183,7 @@ export function getDashboardType(id: unknown) { if (id == null || typeof id === "object") { // HACK: support inline dashboards return "inline"; - } else if (isUUID(id)) { + } else if (isUuid(id)) { return "public"; } else if (isJWT(id)) { return "embed"; diff --git a/frontend/src/metabase/lib/utils.ts b/frontend/src/metabase/lib/utils.ts index a7dc02eb659..830fafaa9b3 100644 --- a/frontend/src/metabase/lib/utils.ts +++ b/frontend/src/metabase/lib/utils.ts @@ -6,12 +6,6 @@ import { PLUGIN_IS_EE_BUILD } from "metabase/plugins"; const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; -function s4() { - return Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); -} - export function isEmpty(str: string | null) { if (str != null) { str = String(str); @@ -41,30 +35,6 @@ export function numberToWord(num: number) { } } -export function uuid() { - return ( - s4() + - s4() + - "-" + - s4() + - "-" + - s4() + - "-" + - s4() + - "-" + - s4() + - s4() + - s4() - ); -} - -export function isUUID(uuid: unknown) { - return ( - typeof uuid === "string" && - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid) - ); -} - export function isJWT(string: unknown) { return ( typeof string === "string" && diff --git a/frontend/src/metabase/lib/uuid.ts b/frontend/src/metabase/lib/uuid.ts new file mode 100644 index 00000000000..d9c1e40418d --- /dev/null +++ b/frontend/src/metabase/lib/uuid.ts @@ -0,0 +1,29 @@ +export function uuid() { + return ( + s4() + + s4() + + "-" + + s4() + + "-" + + s4() + + "-" + + s4() + + "-" + + s4() + + s4() + + s4() + ); +} + +function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); +} + +export function isUuid(uuid: unknown): uuid is string { + return ( + typeof uuid === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid) + ); +} diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp/TagEditorHelp.tsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp/TagEditorHelp.tsx index 6e86de5fc0f..7ed4865bc82 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp/TagEditorHelp.tsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp/TagEditorHelp.tsx @@ -7,7 +7,7 @@ import ExternalLink from "metabase/core/components/ExternalLink"; import CS from "metabase/css/core/index.css"; import { useSelector } from "metabase/lib/redux"; import MetabaseSettings from "metabase/lib/settings"; -import { uuid } from "metabase/lib/utils"; +import { uuid } from "metabase/lib/uuid"; import { getShowMetabaseLinks } from "metabase/selectors/whitelabel"; import type Database from "metabase-lib/v1/metadata/Database"; import type { DatabaseId, NativeDatasetQuery } from "metabase-types/api"; diff --git a/frontend/src/metabase/visualizations/visualizations/SmartScalar/SettingsComponents/SmartScalarSettingsWidgets.tsx b/frontend/src/metabase/visualizations/visualizations/SmartScalar/SettingsComponents/SmartScalarSettingsWidgets.tsx index f60ce9680d8..eb774a45a94 100644 --- a/frontend/src/metabase/visualizations/visualizations/SmartScalar/SettingsComponents/SmartScalarSettingsWidgets.tsx +++ b/frontend/src/metabase/visualizations/visualizations/SmartScalar/SettingsComponents/SmartScalarSettingsWidgets.tsx @@ -1,21 +1,20 @@ import type { DragEndEvent } from "@dnd-kit/core"; -import { DndContext, useSensor, PointerSensor } from "@dnd-kit/core"; +import { DndContext, PointerSensor, useSensor } from "@dnd-kit/core"; import { - restrictToVerticalAxis, restrictToParentElement, + restrictToVerticalAxis, } from "@dnd-kit/modifiers"; import { + SortableContext, arrayMove, verticalListSortingStrategy, - SortableContext, } from "@dnd-kit/sortable"; import { useCallback } from "react"; import { usePreviousDistinct } from "react-use"; import { t } from "ttag"; -import _ from "underscore"; import { Sortable } from "metabase/core/components/Sortable"; -import { uuid } from "metabase/lib/utils"; +import { uuid } from "metabase/lib/uuid"; import { Stack } from "metabase/ui"; import type { DatasetColumn, SmartScalarComparison } from "metabase-types/api"; diff --git a/frontend/src/metabase/visualizations/visualizations/SmartScalar/utils.ts b/frontend/src/metabase/visualizations/visualizations/SmartScalar/utils.ts index fa00f21a63a..28ff1ab4469 100644 --- a/frontend/src/metabase/visualizations/visualizations/SmartScalar/utils.ts +++ b/frontend/src/metabase/visualizations/visualizations/SmartScalar/utils.ts @@ -4,7 +4,7 @@ import _ from "underscore"; import { formatNumber } from "metabase/lib/formatting/numbers"; import { measureText } from "metabase/lib/measure-text"; -import { uuid } from "metabase/lib/utils"; +import { uuid } from "metabase/lib/uuid"; import { isEmpty } from "metabase/lib/validate"; import { isDate, isNumeric } from "metabase-lib/v1/types/utils/isa"; import type { -- GitLab