From d32039aca96f4929ceb77b6ca8ca47ab05557d1e Mon Sep 17 00:00:00 2001 From: Tom Robinson <tlrobinson@gmail.com> Date: Wed, 28 Aug 2019 18:18:17 -0700 Subject: [PATCH] Fix drill through to question user doesn't have all data permissions for (#10655) * Fix drill through to question user doesn't have all data permissions for. Resolves #7928 * Fix flow * Fix #10656 * Tests * Fix lint + add a couple cute aliases * fix object detail logic + add test --- frontend/src/metabase-lib/lib/Mode.js | 4 +- .../src/metabase-lib/lib/metadata/Field.js | 4 + .../lib/queries/StructuredQuery.js | 26 ++++ .../lib/queries/structured/Breakout.js | 14 +- frontend/src/metabase/app-main.js | 1 + frontend/src/metabase/modes/lib/modes.js | 71 ++++----- .../src/metabase/query_builder/selectors.js | 23 +-- .../__support__/sample_dataset_fixture.js | 1 + .../test/metabase-lib/lib/Mode.unit.spec.js | 139 +++++++++++++++--- 9 files changed, 202 insertions(+), 81 deletions(-) diff --git a/frontend/src/metabase-lib/lib/Mode.js b/frontend/src/metabase-lib/lib/Mode.js index c91589e9f17..b3d4668e2ef 100644 --- a/frontend/src/metabase-lib/lib/Mode.js +++ b/frontend/src/metabase-lib/lib/Mode.js @@ -20,9 +20,7 @@ export default class Mode { static forQuestion(question: Question): ?Mode { // TODO Atte Keinänen 6/22/17: Move getMode here and refactor it after writing tests - const card = question.card(); - const tableMetadata = question.tableMetadata(); - const queryMode = getMode(card, tableMetadata); + const queryMode = getMode(question); if (queryMode) { return new Mode(question, queryMode); diff --git a/frontend/src/metabase-lib/lib/metadata/Field.js b/frontend/src/metabase-lib/lib/metadata/Field.js index d374572bc68..39316284294 100644 --- a/frontend/src/metabase-lib/lib/metadata/Field.js +++ b/frontend/src/metabase-lib/lib/metadata/Field.js @@ -19,6 +19,7 @@ import { isString, isSummable, isCategory, + isAddress, isState, isCountry, isCoordinate, @@ -95,6 +96,9 @@ export default class Field extends Base { isString() { return isString(this); } + isAddress() { + return isAddress(this); + } isState() { return isState(this); } diff --git a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js index 347978750e2..3b18a7e5843 100644 --- a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js +++ b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js @@ -500,6 +500,32 @@ export default class StructuredQuery extends AtomicQuery { return this.fields().length > 0; } + // ALIASES: allows + + /** + * @returns alias for addAggregation + */ + aggregate(aggregation: Aggregation): StructuredQuery { + return this.addAggregation(aggregation); + } + + /** + * @returns alias for addBreakout + */ + breakout(breakout: Breakout | Dimension): StructuredQuery { + if (breakout instanceof Dimension) { + breakout = breakout.mbql(); + } + return this.addBreakout(breakout); + } + + /** + * @returns alias for addFilter + */ + filter(filter: Filter | FilterWrapper) { + return this.addFilter(filter); + } + // JOINS /** diff --git a/frontend/src/metabase-lib/lib/queries/structured/Breakout.js b/frontend/src/metabase-lib/lib/queries/structured/Breakout.js index 47150d8b0db..a532e23b4c8 100644 --- a/frontend/src/metabase-lib/lib/queries/structured/Breakout.js +++ b/frontend/src/metabase-lib/lib/queries/structured/Breakout.js @@ -3,8 +3,9 @@ import MBQLClause from "./MBQLClause"; import type { Breakout as BreakoutObject } from "metabase/meta/types/Query"; -import type StructuredQuery from "../StructuredQuery"; -import type Dimension from "../../Dimension"; +import type StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; +import type Dimension from "metabase-lib/lib/Dimension"; +import type Field from "metabase-lib/lib/metadata/Field"; export default class Breakout extends MBQLClause { /** @@ -50,9 +51,16 @@ export default class Breakout extends MBQLClause { } /** - * Returns the breakout's dimension + * Returns the breakout's Dimension */ dimension(): Dimension { return this._query.parseFieldReference(this); } + + /** + * Returns the breakout's Field + */ + field(): Field { + return this.dimension().field(); + } } diff --git a/frontend/src/metabase/app-main.js b/frontend/src/metabase/app-main.js index 5dba41cdfe6..162592ede4f 100644 --- a/frontend/src/metabase/app-main.js +++ b/frontend/src/metabase/app-main.js @@ -17,6 +17,7 @@ const WHITELIST_FORBIDDEN_URLS = [ // we should gracefully handle cases where we don't have access to metadata /api\/database\/\d+\/metadata$/, /api\/database\/\d+\/fields/, + /api\/field\/\d+/, /api\/field\/\d+\/values/, /api\/table\/\d+\/query_metadata$/, /api\/table\/\d+\/fks$/, diff --git a/frontend/src/metabase/modes/lib/modes.js b/frontend/src/metabase/modes/lib/modes.js index c443fc61f87..ca861d0d5c4 100644 --- a/frontend/src/metabase/modes/lib/modes.js +++ b/frontend/src/metabase/modes/lib/modes.js @@ -1,15 +1,5 @@ /* @flow weak */ -import * as Q_DEPRECATED from "metabase/lib/query"; // legacy query lib -import { - isDate, - isAddress, - isCategory, - isPK, -} from "metabase/lib/schema_metadata"; -import * as Query from "metabase/lib/query/query"; -import * as Card from "metabase/meta/Card"; - import ObjectMode from "../components/modes/ObjectMode"; import SegmentMode from "../components/modes/SegmentMode"; import MetricMode from "../components/modes/MetricMode"; @@ -19,51 +9,45 @@ import PivotMode from "../components/modes/PivotMode"; import NativeMode from "../components/modes/NativeMode"; import DefaultMode from "../components/modes/DefaultMode"; -import type { Card as CardObject } from "metabase/meta/types/Card"; -import type { TableMetadata } from "metabase/meta/types/Metadata"; import type { QueryMode } from "metabase/meta/types/Visualization"; -import _ from "underscore"; +import type Question from "metabase-lib/lib/Question"; + +import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; +import NativeQuery from "metabase-lib/lib/queries/NativeQuery"; -export function getMode( - card: CardObject, - tableMetadata: ?TableMetadata, -): ?QueryMode { - if (!card) { +export function getMode(question: ?Question): ?QueryMode { + if (!question) { return null; } - if (Card.isNative(card)) { + const query = question.query(); + + if (query instanceof NativeQuery) { return NativeMode; } - const query = Card.getQuery(card); - if (Card.isStructured(card) && query) { - if (!tableMetadata) { - return null; - } - - const aggregations = Query.getAggregations(query); - const breakouts = Query.getBreakouts(query); - const filters = Query.getFilters(query); + if (query instanceof StructuredQuery) { + const aggregations = query.aggregations(); + const breakouts = query.breakouts(); + const filters = query.filters(); if (aggregations.length === 0 && breakouts.length === 0) { const isPKFilter = filter => { - if (tableMetadata && Array.isArray(filter) && filter[0] === "=") { - const fieldId = Q_DEPRECATED.getFieldTargetId(filter[1]); - const field = tableMetadata.fields_lookup[fieldId]; + if (filter.isFieldFilter()) { + const field = filter.field(); if ( field && + field.isPK() && field.table && - field.table.id === query["source-table"] && - isPK(field) + field.table.id === query.sourceTableId() ) { return true; } } return false; }; - if (_.any(filters, isPKFilter)) { + if (filters.some(isPKFilter)) { return ObjectMode; } else { return SegmentMode; @@ -73,26 +57,23 @@ export function getMode( return MetricMode; } if (aggregations.length > 0 && breakouts.length > 0) { - const breakoutFields = breakouts.map( - breakout => - (Q_DEPRECATED.getFieldTarget(breakout, tableMetadata) || {}).field, - ); + const breakoutFields = breakouts.map(b => b.field()); if ( - (breakoutFields.length === 1 && isDate(breakoutFields[0])) || + (breakoutFields.length === 1 && breakoutFields[0].isDate()) || (breakoutFields.length === 2 && - isDate(breakoutFields[0]) && - isCategory(breakoutFields[1])) + breakoutFields[0].isDate() && + breakoutFields[1].isCategory()) ) { return TimeseriesMode; } - if (breakoutFields.length === 1 && isAddress(breakoutFields[0])) { + if (breakoutFields.length === 1 && breakoutFields[0].isAddress()) { return GeoMode; } if ( - (breakoutFields.length === 1 && isCategory(breakoutFields[0])) || + (breakoutFields.length === 1 && breakoutFields[0].isCategory()) || (breakoutFields.length === 2 && - isCategory(breakoutFields[0]) && - isCategory(breakoutFields[1])) + breakoutFields[0].isCategory() && + breakoutFields[1].isCategory()) ) { return PivotMode; } diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index 8bae3caf327..27a2de61419 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -1,3 +1,5 @@ +/*eslint no-use-before-define: "error"*/ + import { createSelector } from "reselect"; import _ from "underscore"; import { getIn } from "icepick"; @@ -6,7 +8,6 @@ import { getIn } from "icepick"; // eslint-disable-next-line no-unused-vars import Visualization from "metabase/visualizations/components/Visualization"; -import { getMode as getMode_ } from "metabase/modes/lib/modes"; import { extractRemappings, getVisualizationTransformed, @@ -110,16 +111,6 @@ export const getDatabaseFields = createSelector( (databaseId, databaseFields) => [], // FIXME! ); -export const getMode = createSelector( - [getLastRunCard, getTableMetadata], - (card, tableMetadata) => getMode_(card, tableMetadata), -); - -export const getIsObjectDetail = createSelector( - [getMode], - mode => mode && mode.name === "object", -); - export const getParameters = createSelector( [getCard, getParameterValues], (card, parameterValues) => getParametersWithExtras(card, parameterValues), @@ -193,6 +184,16 @@ export const getOriginalQuestion = createSelector( }, ); +export const getMode = createSelector( + [getLastRunQuestion], + question => question && question.mode(), +); + +export const getIsObjectDetail = createSelector( + [getMode], + mode => mode && mode.name() === "object", +); + export const getIsDirty = createSelector( [getQuestion, getOriginalQuestion], (question, originalQuestion) => { diff --git a/frontend/test/__support__/sample_dataset_fixture.js b/frontend/test/__support__/sample_dataset_fixture.js index 9bbfbe1977d..20b2cf13184 100644 --- a/frontend/test/__support__/sample_dataset_fixture.js +++ b/frontend/test/__support__/sample_dataset_fixture.js @@ -24,6 +24,7 @@ export const ORDERS_PRODUCT_FK_FIELD_ID = 3; export const ORDERS_SUBTOTAL_FIELD_ID = 4; export const ORDERS_TAX_FIELD_ID = 5; export const ORDERS_TOTAL_FIELD_ID = 6; +export const ORDERS_USER_FK_FIELD_ID = 7; export const MAIN_METRIC_ID = 1; diff --git a/frontend/test/metabase-lib/lib/Mode.unit.spec.js b/frontend/test/metabase-lib/lib/Mode.unit.spec.js index acd81bccaa6..8b2c91553ee 100644 --- a/frontend/test/metabase-lib/lib/Mode.unit.spec.js +++ b/frontend/test/metabase-lib/lib/Mode.unit.spec.js @@ -2,41 +2,130 @@ import { metadata, DATABASE_ID, ORDERS_TABLE_ID, + ORDERS_PK_FIELD_ID, + ORDERS_CREATED_DATE_FIELD_ID, + ORDERS_PRODUCT_FK_FIELD_ID, + ORDERS_USER_FK_FIELD_ID, + PRODUCT_CATEGORY_FIELD_ID, + PEOPLE_STATE_FIELD_ID, orders_raw_card, } from "__support__/sample_dataset_fixture"; import Question from "metabase-lib/lib/Question"; describe("Mode", () => { - const rawDataQuestionMode = new Question(metadata, orders_raw_card).mode(); - const timeBreakoutQuestionMode = Question.create({ - databaseId: DATABASE_ID, - tableId: ORDERS_TABLE_ID, - metadata, - }) - .query() - .addAggregation(["count"]) - .addBreakout(["datetime-field", ["field-id", 1], "day"]) - .question() - .setDisplay("table") - .mode(); + const rawDataQuestion = new Question(metadata, orders_raw_card); + const rawDataQuery = rawDataQuestion.query(); + const rawDataQuestionMode = rawDataQuestion.mode(); describe("forQuestion(question)", () => { describe("with structured query question", () => { // testbed for generative testing? see http://leebyron.com/testcheck-js - it("returns `segment` mode with raw data", () => {}); + it("returns `segment` mode with raw data", () => { + const mode = rawDataQuery.question().mode(); + expect(mode && mode.name()).toEqual("segment"); + }); + + it("returns `metric` mode with >= 1 aggregations", () => { + const mode = rawDataQuery + .aggregate(["count"]) + .question() + .mode(); + expect(mode && mode.name()).toEqual("metric"); + }); - it("returns `metric` mode with >= 1 aggregations", () => {}); + it("returns `timeseries` mode with >=1 aggregations and date breakout", () => { + const mode = rawDataQuery + .aggregate(["count"]) + .breakout([ + "datetime-field", + ["field-id", ORDERS_CREATED_DATE_FIELD_ID], + "day", + ]) + .question() + .mode(); + expect(mode && mode.name()).toEqual("timeseries"); + }); + it("returns `timeseries` mode with >=1 aggregations and date + category breakout", () => { + const mode = rawDataQuery + .aggregate(["count"]) + .breakout([ + "datetime-field", + ["field-id", ORDERS_CREATED_DATE_FIELD_ID], + "day", + ]) + .breakout([ + "fk->", + ["field-id", ORDERS_PRODUCT_FK_FIELD_ID], + ["field-id", PRODUCT_CATEGORY_FIELD_ID], + ]) + .question() + .mode(); + expect(mode && mode.name()).toEqual("timeseries"); + }); - it("returns `timeseries` mode with >=1 aggregations and date breakout", () => {}); - it("returns `timeseries` mode with >=1 aggregations and date + category breakout", () => {}); + it("returns `geo` mode with >=1 aggregations and an address breakout", () => { + const mode = rawDataQuery + .aggregate(["count"]) + .breakout([ + "fk->", + ["field-id", ORDERS_USER_FK_FIELD_ID], + ["field-id", PEOPLE_STATE_FIELD_ID], + ]) + .question() + .mode(); + expect(mode && mode.name()).toEqual("geo"); + }); - it("returns `geo` mode with >=1 aggregations and an address breakout", () => {}); + it("returns `pivot` mode with >=1 aggregations and 1-2 category breakouts", () => { + const mode = rawDataQuery + .aggregate(["count"]) + .breakout([ + "fk->", + ["field-id", ORDERS_PRODUCT_FK_FIELD_ID], + ["field-id", PRODUCT_CATEGORY_FIELD_ID], + ]) + .breakout([ + "fk->", + ["field-id", ORDERS_USER_FK_FIELD_ID], + ["field-id", PEOPLE_STATE_FIELD_ID], + ]) + .question() + .mode(); + expect(mode && mode.name()).toEqual("pivot"); + }); - it("returns `pivot` mode with >=1 aggregations and 1-2 category breakouts", () => {}); + it("returns `object` mode with pk filter", () => { + const mode = rawDataQuery + .filter(["=", ["field-id", ORDERS_PK_FIELD_ID], 42]) + .question() + .mode(); + expect(mode && mode.name()).toEqual("object"); + }); - it("returns `default` mode with >=0 aggregations and >=3 breakouts", () => {}); + it("returns `default` mode with >=0 aggregations and >=3 breakouts", () => { + const mode = rawDataQuery + .aggregate(["count"]) + .breakout([ + "datetime-field", + ["field-id", ORDERS_CREATED_DATE_FIELD_ID], + "day", + ]) + .breakout([ + "fk->", + ["field-id", ORDERS_PRODUCT_FK_FIELD_ID], + ["field-id", PRODUCT_CATEGORY_FIELD_ID], + ]) + .breakout([ + "fk->", + ["field-id", ORDERS_USER_FK_FIELD_ID], + ["field-id", PEOPLE_STATE_FIELD_ID], + ]) + .question() + .mode(); + expect(mode && mode.name()).toEqual("default"); + }); it("returns `default` mode with >=1 aggregations and >=1 breakouts when first neither date or category", () => {}); }); describe("with native query question", () => { @@ -83,6 +172,18 @@ describe("Mode", () => { }); describe("for a question with an aggregation and a time breakout", () => { + const timeBreakoutQuestionMode = Question.create({ + databaseId: DATABASE_ID, + tableId: ORDERS_TABLE_ID, + metadata, + }) + .query() + .aggregate(["count"]) + .breakout(["datetime-field", ["field-id", 1], "day"]) + .question() + .setDisplay("table") + .mode(); + it("has pivot as mode actions 1 and 2", () => { expect(timeBreakoutQuestionMode.actions()[0].name).toBe( "pivot-by-category", -- GitLab