diff --git a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js index fc467a71254ec6008f93c25c617adf930b34d1aa..f90ca4cdd2e8466402ae8fc1e5a198ebe1c0a63f 100644 --- a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js +++ b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js @@ -3,6 +3,7 @@ */ import * as Q from "metabase/lib/query/query"; +import { getUniqueExpressionName } from "metabase/lib/query/expression"; import { format as formatExpression, DISPLAY_QUOTES, @@ -999,23 +1000,32 @@ export default class StructuredQuery extends AtomicQuery { } addExpression(name, expression) { - let query = this._updateQuery(Q.addExpression, arguments); + const uniqueName = getUniqueExpressionName(this.expressions(), name); + let query = this._updateQuery(Q.addExpression, [uniqueName, expression]); // extra logic for adding expressions in fields clause // TODO: push into query/expression? if (query.hasFields() && query.isRaw()) { - query = query.addField(["expression", name]); + query = query.addField(["expression", uniqueName]); } return query; } updateExpression(name, expression, oldName) { - let query = this._updateQuery(Q.updateExpression, arguments); + const isRename = oldName && oldName !== name; + const uniqueName = isRename + ? getUniqueExpressionName(this.expressions(), name) + : name; + let query = this._updateQuery(Q.updateExpression, [ + uniqueName, + expression, + oldName, + ]); // extra logic for renaming expressions in fields clause // TODO: push into query/expression? - if (name !== oldName) { + if (isRename) { const index = query._indexOfField(["expression", oldName]); if (index >= 0) { - query = query.updateField(index, ["expression", name]); + query = query.updateField(index, ["expression", uniqueName]); } } return query; diff --git a/frontend/src/metabase/lib/query/expression.js b/frontend/src/metabase/lib/query/expression.js index adfdda1917abc2d6f2363d65277090ca3f1649ce..a4f21c0fb3ea3e81c5488f6a419ec76c675536f7 100644 --- a/frontend/src/metabase/lib/query/expression.js +++ b/frontend/src/metabase/lib/query/expression.js @@ -50,3 +50,39 @@ export function clearExpressions( ): ?ExpressionClause { return {}; } + +/** + * Ensures expression's name uniqueness + * + * Example: if query has a "Double Total" expression, + * and we're adding a new "Double Total" expression, + * the second expression will be called "Double Total (1)", + * the next one will be "Double Total (2)" and so on + * + * If the original name is already unique, the fn just returns it + * + * @param {string} originalName - expression's name + * @param {object} expressions - object with existing query expressions + * @returns {string} + */ +export function getUniqueExpressionName(expressions, originalName) { + if (!expressions[originalName]) { + return originalName; + } + const expressionNames = Object.keys(expressions); + const handledDuplicateNamePattern = new RegExp( + `^${originalName} \\([0-9]+\\)$`, + ); + const duplicateNames = expressionNames.filter( + name => name === originalName || handledDuplicateNamePattern.test(name), + ); + return getUniqueName(duplicateNames, originalName, duplicateNames.length); +} + +function getUniqueName(expressionNames, originalName, index) { + const nameWithIndexAppended = `${originalName} (${index})`; + const isUnique = !expressionNames.includes(nameWithIndexAppended); + return isUnique + ? nameWithIndexAppended + : getUniqueName(expressionNames, originalName, index + 1); +} diff --git a/frontend/test/__support__/e2e/cypress.js b/frontend/test/__support__/e2e/cypress.js index 480bfa1ac91b63e60ddad7d14c9bf6d578ff6728..a99b3b12f74e52bdb65c77497e0437a16febacfc 100644 --- a/frontend/test/__support__/e2e/cypress.js +++ b/frontend/test/__support__/e2e/cypress.js @@ -17,6 +17,7 @@ export * from "./helpers/e2e-qa-databases-helpers"; export * from "./helpers/e2e-ad-hoc-question-helpers"; export * from "./helpers/e2e-enterprise-helpers"; export * from "./helpers/e2e-mock-app-settings-helpers"; +export * from "./helpers/e2e-notebook-helpers"; export * from "./helpers/e2e-assertion-helpers"; export * from "./helpers/e2e-cloud-helpers"; export * from "./helpers/e2e-data-model-helpers"; diff --git a/frontend/test/__support__/e2e/helpers/e2e-notebook-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-notebook-helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..2509ab2342dfdb9e81be36e8c84c80cc585f044a --- /dev/null +++ b/frontend/test/__support__/e2e/helpers/e2e-notebook-helpers.js @@ -0,0 +1,13 @@ +/** + * Helps to select specific notebook steps like filters, joins, break outs, etc. + * + * Details: + * https://github.com/metabase/metabase/pull/17708#discussion_r700082403 + * + * @param {string} type — notebook step type (filter, join, expression, summarize, etc.) + * @param {{stage: number; index: number}} positionConfig - indexes specifying step's position + * @returns + */ +export function getNotebookStep(type, { stage = 0, index = 0 } = {}) { + return cy.findByTestId(`step-${type}-${stage}-${index}`); +} diff --git a/frontend/test/metabase-lib/lib/queries/StructuredQuery-expressions.unit.spec.js b/frontend/test/metabase-lib/lib/queries/StructuredQuery-expressions.unit.spec.js index 62308661125f54115e40ce83dbe817770b75b182..a8331517a8306af6d861c7354ddff7be882ddd1f 100644 --- a/frontend/test/metabase-lib/lib/queries/StructuredQuery-expressions.unit.spec.js +++ b/frontend/test/metabase-lib/lib/queries/StructuredQuery-expressions.unit.spec.js @@ -1,18 +1,251 @@ -import { ORDERS } from "__support__/sample_dataset_fixture"; +import { ORDERS, SAMPLE_DATASET } from "__support__/sample_dataset_fixture"; +import Question from "metabase-lib/lib/Question"; + +const TEST_EXPRESSION = ["+", 1, 1]; +const TEST_EXPRESSION_2 = ["+", 2, 2]; +const TEST_EXPRESSION_3 = ["+", 3, 3]; + +function getQuery({ expressions } = {}) { + const question = new Question({ + dataset_query: { + type: "query", + database: SAMPLE_DATASET.id, + query: { + "source-table": ORDERS.id, + expressions: expressions, + }, + }, + }); + return question.query(); +} describe("StructuredQuery", () => { + describe("expressions", () => { + it("should return empty object when there are no expressions", () => { + expect(getQuery().expressions()).toEqual({}); + }); + + it("should return expressions object", () => { + const query = getQuery({ + expressions: { + double_total: TEST_EXPRESSION, + diff: TEST_EXPRESSION_2, + }, + }); + + expect(query.expressions()).toEqual({ + double_total: TEST_EXPRESSION, + diff: TEST_EXPRESSION_2, + }); + }); + }); + describe("hasExpressions", () => { it("should return false for queries without expressions", () => { - const q = ORDERS.query(); - expect(q.hasExpressions()).toBe(false); + const query = getQuery(); + expect(query.hasExpressions()).toBe(false); }); + it("should return true for queries with expressions", () => { - const q = ORDERS.query().addExpression("double_total", [ - "*", - ["field", ORDERS.TOTAL.id, null], - 2, - ]); - expect(q.hasExpressions()).toBe(true); + const query = getQuery({ + expressions: { + double_total: TEST_EXPRESSION, + }, + }); + expect(query.hasExpressions()).toBe(true); + }); + }); + + describe("addExpression", () => { + it("should add new expressions correctly", () => { + let query = getQuery(); + + query = query.addExpression("double_total", TEST_EXPRESSION); + + expect(query.expressions()).toEqual({ + double_total: TEST_EXPRESSION, + }); + }); + + it("should append indexes to duplicate names", () => { + let query = getQuery({ + expressions: { double_total: TEST_EXPRESSION }, + }); + + query = query + .addExpression("double_total", TEST_EXPRESSION) + .addExpression("double_total", TEST_EXPRESSION); + + expect(query.expressions()).toEqual({ + double_total: TEST_EXPRESSION, + "double_total (1)": TEST_EXPRESSION, + "double_total (2)": TEST_EXPRESSION, + }); + }); + + it("should handle gaps in names with indexes appended", () => { + let query = getQuery({ + expressions: { double_total: TEST_EXPRESSION }, + }); + + query = query + .addExpression("double_total", TEST_EXPRESSION) + .addExpression("double_total", TEST_EXPRESSION_2) + .removeExpression("double_total (1)", TEST_EXPRESSION) + .addExpression("double_total", TEST_EXPRESSION); + + expect(query.expressions()).toEqual({ + double_total: TEST_EXPRESSION, + "double_total (2)": TEST_EXPRESSION_2, + "double_total (3)": TEST_EXPRESSION, + }); + }); + + it("should detect duplicate names in case-sensitive way", () => { + let query = getQuery({ + expressions: { double_total: TEST_EXPRESSION }, + }); + + query = query.addExpression("DOUBLE_TOTAL", TEST_EXPRESSION); + + expect(query.expressions()).toEqual({ + double_total: TEST_EXPRESSION, + DOUBLE_TOTAL: TEST_EXPRESSION, + }); + }); + + it("should not append indexes when expression names are similar", () => { + let query = getQuery({ + expressions: { double_total: TEST_EXPRESSION }, + }); + + query = query + .addExpression("double_total", TEST_EXPRESSION) + .addExpression("double", TEST_EXPRESSION) + .addExpression("total", TEST_EXPRESSION) + .addExpression("double_total", TEST_EXPRESSION) + .addExpression("foo_double_total", TEST_EXPRESSION) + .addExpression("double_total_bar", TEST_EXPRESSION) + .addExpression("double_total", TEST_EXPRESSION); + + expect(query.expressions()).toEqual({ + double_total: TEST_EXPRESSION, + "double_total (1)": TEST_EXPRESSION, + "double_total (2)": TEST_EXPRESSION, + "double_total (3)": TEST_EXPRESSION, + double: TEST_EXPRESSION, + total: TEST_EXPRESSION, + foo_double_total: TEST_EXPRESSION, + double_total_bar: TEST_EXPRESSION, + }); + }); + }); + + describe("updateExpression", () => { + it("should update expressions correctly", () => { + let query = getQuery({ + expressions: { + double_total: TEST_EXPRESSION, + }, + }); + + query = query.updateExpression("double_total", TEST_EXPRESSION_2); + + expect(query.expressions()).toEqual({ + double_total: TEST_EXPRESSION_2, + }); + }); + + it("should update expression names correctly", () => { + let query = getQuery({ + expressions: { + double_total: TEST_EXPRESSION, + }, + }); + + query = query.updateExpression( + "Double Total", + TEST_EXPRESSION, + "double_total", + ); + + expect(query.expressions()).toEqual({ + "Double Total": TEST_EXPRESSION, + }); + }); + + it("should handle duplicate expression names", () => { + let query = getQuery({ + expressions: { + double_total: TEST_EXPRESSION, + "double_total (1)": TEST_EXPRESSION_2, + "double_total (2)": TEST_EXPRESSION_3, + }, + }); + + query = query.updateExpression( + "double_total", + TEST_EXPRESSION_3, + "double_total (2)", + ); + + expect(query.expressions()).toEqual({ + double_total: TEST_EXPRESSION, + "double_total (1)": TEST_EXPRESSION_2, + "double_total (3)": TEST_EXPRESSION_3, + }); + }); + }); + + describe("removeExpression", () => { + it("should remove expression correctly", () => { + let query = getQuery({ + expressions: { + double_total: TEST_EXPRESSION, + diff: TEST_EXPRESSION_2, + }, + }); + + query = query.removeExpression("double_total"); + + expect(query.expressions()).toEqual({ + diff: TEST_EXPRESSION_2, + }); + }); + + it("should do nothing when removing not existing expression", () => { + let query = getQuery({ + expressions: { + double_total: TEST_EXPRESSION, + }, + }); + + query = query.removeExpression("this_query_has_no_expression_like_that"); + + expect(query.expressions()).toEqual({ + double_total: TEST_EXPRESSION, + }); + }); + }); + + describe("clearExpressions", () => { + it("should remove all existing expressions", () => { + let query = getQuery({ + expressions: { + double_total: TEST_EXPRESSION, + diff: TEST_EXPRESSION_2, + }, + }); + + query = query.clearExpressions(); + + expect(query.expressions()).toEqual({}); + }); + + it("should do nothing if there are no expressions", () => { + let query = getQuery(); + query = query.clearExpressions(); + expect(query.expressions()).toEqual({}); }); }); }); diff --git a/frontend/test/metabase/scenarios/question/notebook.cy.spec.js b/frontend/test/metabase/scenarios/question/notebook.cy.spec.js index fc4034256cb550fce196287efee74618be4f3448..b4a073b526b72f0116063ddb7a7dac20da53db55 100644 --- a/frontend/test/metabase/scenarios/question/notebook.cy.spec.js +++ b/frontend/test/metabase/scenarios/question/notebook.cy.spec.js @@ -7,6 +7,7 @@ import { modal, visitQuestionAdhoc, interceptPromise, + getNotebookStep, } from "__support__/e2e/cypress"; import { SAMPLE_DATASET } from "__support__/e2e/cypress_sample_dataset"; @@ -133,6 +134,37 @@ describe("scenarios > question > notebook", () => { }); }); + it("should append indexes to duplicate custom expression names (metabase#12104)", () => { + cy.intercept("POST", "/api/dataset").as("dataset"); + openProductsTable({ mode: "notebook" }); + + cy.findByText("Custom column").click(); + addSimpleCustomColumn("EXPR"); + + getNotebookStep("expression").within(() => { + cy.icon("add").click(); + }); + addSimpleCustomColumn("EXPR"); + + getNotebookStep("expression").within(() => { + cy.icon("add").click(); + }); + addSimpleCustomColumn("EXPR"); + + getNotebookStep("expression").within(() => { + cy.findByText("EXPR"); + cy.findByText("EXPR (1)"); + cy.findByText("EXPR (2)"); + }); + + cy.button("Visualize").click(); + cy.wait("@dataset"); + + cy.findByText("EXPR"); + cy.findByText("EXPR (1)"); + cy.findByText("EXPR (2)"); + }); + it("should process the updated expression when pressing Enter", () => { openProductsTable({ mode: "notebook" }); cy.findByText("Filter").click(); @@ -1022,3 +1054,13 @@ function joinTwoSavedQuestions() { }); }); } + +function addSimpleCustomColumn(name) { + popover() + .findByText("Category") + .click(); + cy.findByPlaceholderText("Something nice and descriptive") + .click() + .type(name); + cy.button("Done").click(); +}