Skip to content
Snippets Groups Projects
Commit 136dfb17 authored by Atte Keinänen's avatar Atte Keinänen Committed by GitHub
Browse files

Merge pull request #5134 from metabase/multi-metrics-proto

Metabase-lib
parents bd6e1ce7 7f2e6355
No related branches found
No related tags found
No related merge requests found
Showing
with 2385 additions and 1 deletion
......@@ -42,6 +42,7 @@
"flowtype/use-flow-type": 1
},
"globals": {
"pending": false
},
"env": {
"browser": true,
......
[ignore]
.*/node_modules/react/node_modules/.*
.*/node_modules/postcss-import/node_modules/.*
.*/node_modules/documentation/.*
.*/node_modules/.*/\(lib\|test\).*\.json$
[include]
......
// Origin: https://github.com/flowtype/flow-typed/blob/master/definitions/npm/redux-actions_v2.x.x/flow_v0.34.x-/redux-actions_v2.x.x.js
declare module 'redux-actions' {
/*
* Use `ActionType` to get the type of the action created by a given action
* creator. For example:
*
* import { creatAction, type ActionType } from 'redux-actions'
*
* const increment = createAction(INCREMENT, (count: number) => count)
*
* function myReducer(state: State = initState, action: ActionType<typeof increment>): State {
* // Flow will infer that the type of `action.payload` is `number`
* }
*/
declare type ActionType<ActionCreator> = _ActionType<*, ActionCreator>;
declare type _ActionType<R, Fn: (payload: *, ...rest: any[]) => R> = R;
/*
* To get the most from Flow type checking use a `payloadCreator` argument
* with `createAction`. Make sure that Flow can infer the argument type of the
* `payloadCreator`. That will allow Flow to infer the payload type of actions
* created by that action creator in other parts of the program. For example:
*
* const increment = createAction(INCREMENT, (count: number) => count)
*
*/
declare function createAction<T, P>(
type: T,
$?: empty // hack to force Flow to not use this signature when more than one argument is given
): (payload: P, ...rest: any[]) => { type: T, payload: P, error?: boolean };
declare function createAction<T, P, P2>(
type: T,
payloadCreator: (_: P) => P2,
$?: empty
): (payload: P, ...rest: any[]) => { type: T, payload: P2, error?: boolean };
declare function createAction<T, P, P2, M>(
type: T,
payloadCreator: (_: P) => P2,
metaCreator: (_: P) => M
): (payload: P, ...rest: any[]) => { type: T, payload: P2, error?: boolean, meta: M };
declare function createAction<T, P, M>(
type: T,
payloadCreator: null | void,
metaCreator: (_: P) => M
): (payload: P, ...rest: any[]) => { type: T, payload: P, error?: boolean, meta: M };
// `createActions` is quite difficult to write a type for. Maybe try not to
// use this one?
declare function createActions(actionMap: Object, ...identityActions: string[]): Object;
declare function createActions(...identityActions: string[]): Object;
declare type Reducer<S, A> = (state: S, action: A) => S;
declare type ReducerMap<S, A> =
| { next: Reducer<S, A> }
| { throw: Reducer<S, A> }
| { next: Reducer<S, A>, throw: Reducer<S, A> }
/*
* To get full advantage from Flow, use a type annotation on the action
* argument to your reducer when creating a reducer with `handleAction` or
* `handleActions`. For example:
*
* import { type Reducer } from 'redux'
* import { createAction, handleAction, type Action } from 'redux-actions'
*
* const increment = createAction(INCREMENT, (count: number) => count)
*
* const reducer = handleAction(INCREMENT, (state, { payload }: ActionType<typeof increment>) => {
* // Flow infers that the type of `payload` is number
* }, defaultState)
*/
declare function handleAction<Type, State, Action: { type: Type }>(
type: Type,
reducer: Reducer<State, Action> | ReducerMap<State, Action>,
defaultState: State
): Reducer<State, Action>;
declare function handleActions<State, Action>(
reducers: { [key: string]: Reducer<State, Action> | ReducerMap<State, Action> },
defaultState?: State
): Reducer<State, Action>;
declare function combineActions(...types: (string | Symbol | Function)[]) : string;
}
......@@ -28,7 +28,7 @@ declare module "underscore" {
declare function some<T>(a: Array<T>, pred: (val: T)=>boolean): boolean;
declare function all<T>(a: Array<T>, pred: (val: T)=>boolean): boolean;
declare function any<T>(a: Array<T>, pred: (val: T)=>boolean): boolean;
declare function contains<T>(a: Array<T>, pred: (val: T)=>boolean): boolean;
declare function contains<T>(a: Array<T>, val: T): boolean;
declare function initial<T>(a: Array<T>, n?: number): Array<T>;
declare function rest<T>(a: Array<T>, index?: number): Array<T>;
......@@ -38,6 +38,9 @@ declare module "underscore" {
declare function filter<T>(o: {[key:string]: T}, pred: (val: T, k: string)=>boolean): T[];
declare function isEmpty(o: any): boolean;
declare function isString(o: any): boolean;
declare function isObject(o: any): boolean;
declare function isArray(o: any): boolean;
declare function groupBy<T>(a: Array<T>, iteratee: string|(val: T, index: number)=>any): {[key:string]: T[]};
......@@ -53,6 +56,9 @@ declare module "underscore" {
declare function pick(o: {[key: any]: any}, ...properties: string[]): {[key: any]: any};
declare function pick(o: {[key: any]: any}, predicate: (val: any, key: any, object: {[key: any]: any})=>boolean): {[key: any]: any};
declare function pluck(o: Array<{[key: any]: any}>, propertyNames: string): Array<any>;
declare function has(o: {[key: any]: any}, ...properties: string[]): boolean;
declare function difference<T>(array: T[], ...others: T[][]): T[];
declare function flatten(a: Array<any>): Array<any>;
......
{
"rules": {
"flowtype/require-valid-file-annotation": 1
}
}
/* @flow weak */
export default class Action {
perform() {}
}
export class ActionClick {}
import Action from "./Action";
describe("Action", () => {
describe("perform", () => {
it("should perform the action", () => {
new Action().perform();
});
});
});
export default class Dashboard {
getParameters() {}
}
import React from "react";
import Icon from "metabase/components/Icon";
import { stripId, inflect } from "metabase/lib/formatting";
import Query_DEPRECATED from "metabase/lib/query";
import { mbqlEq } from "metabase/lib/query/util";
import _ from "underscore";
import Field from "./metadata/Field";
import Metadata from "./metadata/Metadata";
import type {
ConcreteField,
LocalFieldReference,
ForeignFieldReference,
DatetimeField,
ExpressionReference,
DatetimeUnit
} from "metabase/meta/types/Query";
import type { IconName } from "metabase/meta/types";
/**
* A dimension option returned by the query_metadata API
*/
type DimensionOption = {
mbql: any,
name?: string
};
/**
* Dimension base class, represents an MBQL field reference.
*
* Used for displaying fields (like Created At) and their "sub-dimensions" (like Created At by Day)
* in field lists and active value widgets for filters, aggregations and breakouts.
*
* @abstract
*/
export default class Dimension {
_parent: ?Dimension;
_args: any;
_metadata: ?Metadata;
/**
* Dimension constructor
*/
constructor(
parent: ?Dimension,
args: any[],
metadata?: Metadata
): Dimension {
this._parent = parent;
this._args = args;
this._metadata = metadata || (parent && parent._metadata);
}
/**
* Parses an MBQL expression into an appropriate Dimension subclass, if possible.
* Metadata should be provided if you intend to use the display name or render methods.
*/
static parseMBQL(mbql: ConcreteField, metadata?: Metadata): ?Dimension {
for (const D of DIMENSION_TYPES) {
const dimension = D.parseMBQL(mbql, metadata);
if (dimension != null) {
return dimension;
}
}
return null;
}
/**
* Returns true if these two dimensions are identical to one another.
*/
static isEqual(a: ?Dimension | ConcreteField, b: ?Dimension): boolean {
let dimensionA: ?Dimension = a instanceof Dimension
? a
: // $FlowFixMe
Dimension.parseMBQL(a, this._metadata);
let dimensionB: ?Dimension = b instanceof Dimension
? b
: // $FlowFixMe
Dimension.parseMBQL(b, this._metadata);
return !!dimensionA && !!dimensionB && dimensionA.isEqual(dimensionB);
}
/**
* Sub-dimensions for the provided dimension of this type.
* @abstract
*/
// TODO Atte Keinänen 5/21/17: Rename either this or the instance method with the same name
// Also making it clear in the method name that we're working with sub-dimensions would be good
static dimensions(parent: Dimension): Dimension[] {
return [];
}
/**
* The default sub-dimension for the provided dimension of this type, if any.
* @abstract
*/
static defaultDimension(parent: Dimension): ?Dimension {
return null;
}
/**
* Returns "sub-dimensions" of this dimension.
* @abstract
*/
// TODO Atte Keinänen 5/21/17: Rename either this or the static method with the same name
// Also making it clear in the method name that we're working with sub-dimensions would be good
dimensions(
DimensionTypes: typeof Dimension[] = DIMENSION_TYPES
): Dimension[] {
const dimensionOptions = this.field().dimension_options;
if (dimensionOptions) {
return dimensionOptions.map(option =>
this._dimensionForOption(option));
} else {
return [].concat(
...DimensionTypes.map(DimensionType =>
DimensionType.dimensions(this))
);
}
}
/**
* Returns the default sub-dimension of this dimension, if any.
* @abstract
*/
defaultDimension(DimensionTypes: any[] = DIMENSION_TYPES): ?Dimension {
const defaultDimensionOption = this.field().default_dimension_option;
if (defaultDimensionOption) {
return this._dimensionForOption(defaultDimensionOption);
} else {
for (const DimensionType of DimensionTypes) {
const defaultDimension = DimensionType.defaultDimension(this);
if (defaultDimension) {
return defaultDimension;
}
}
}
return null;
}
// Internal method gets a Dimension from a DimensionOption
_dimensionForOption(option: DimensionOption) {
// fill in the parent field ref
const fieldRef = this.baseDimension().mbql();
let mbql = option.mbql;
if (mbql) {
mbql = [mbql[0], fieldRef, ...mbql.slice(2)];
} else {
mbql = fieldRef;
}
let dimension = Dimension.parseMBQL(mbql, this._metadata);
if (option.name) {
dimension.subDisplayName = () => option.name;
dimension.subTriggerDisplayName = () => option.name;
}
return dimension;
}
/**
* Is this dimension idential to another dimension or MBQL clause
*/
isEqual(other: ?Dimension | ConcreteField): boolean {
if (other == null) {
return false;
}
let otherDimension: ?Dimension = other instanceof Dimension
? other
: Dimension.parseMBQL(other, this._metadata);
if (!otherDimension) {
return false;
}
// must be instace of the same class
if (this.constructor !== otherDimension.constructor) {
return false;
}
// must both or neither have a parent
if (!this._parent !== !otherDimension._parent) {
return false;
}
// parents must be equal
if (this._parent && !this._parent.isEqual(otherDimension._parent)) {
return false;
}
// args must be equal
if (!_.isEqual(this._args, otherDimension._args)) {
return false;
}
return true;
}
/**
* Does this dimension have the same underlying base dimension, typically a field
*/
isSameBaseDimension(other: ?Dimension | ConcreteField): boolean {
if (other == null) {
return false;
}
let otherDimension: ?Dimension = other instanceof Dimension
? other
: Dimension.parseMBQL(other, this._metadata);
const baseDimensionA = this.baseDimension();
const baseDimensionB = otherDimension && otherDimension.baseDimension();
return !!baseDimensionA &&
!!baseDimensionB &&
baseDimensionA.isEqual(baseDimensionB);
}
/**
* The base dimension of this dimension, typically a field. May return itself.
*/
baseDimension(): Dimension {
return this;
}
/**
* The underlying field for this dimension
*/
field(): Field {
return new Field();
}
/**
* Valid operators on this dimension
*/
operators() {
return this.field().operators || [];
}
/**
* The operator with the provided operator name (e.x. `=`, `<`, etc)
*/
operator(op) {
return this.field().operator(op);
}
/**
* The display name of this dimension, e.x. the field's display_name
* @abstract
*/
displayName(): string {
return "";
}
/**
* The name to be shown when this dimension is being displayed as a sub-dimension of another
* @abstract
*/
subDisplayName(): string {
return "";
}
/**
* A shorter version of subDisplayName, e.x. to be shown in the dimension picker trigger
* @abstract
*/
subTriggerDisplayName(): string {
return "";
}
/**
* An icon name representing this dimension's type, to be used in the <Icon> component.
* @abstract
*/
icon(): ?IconName {
return null;
}
/**
* Renders a dimension to React
*/
render(): ?React$Element<any> {
return [this.displayName()];
}
}
/**
* Field based dimension, abstract class for `field-id`, `fk->`, `datetime-field`, etc
* @abstract
*/
export class FieldDimension extends Dimension {
field(): Field {
if (this._parent instanceof FieldDimension) {
return this._parent.field();
}
return new Field();
}
displayName(): string {
return stripId(
Query_DEPRECATED.getFieldPathName(
this.field().id,
this.field().table
)
);
}
subDisplayName(): string {
if (this._parent) {
// foreign key, show the field name
return this.field().display_name;
} else if (this.field().isNumber()) {
return "Continuous (no binning)";
} else {
return "Default";
}
}
icon() {
return this.field().icon();
}
}
/**
* Field ID-based dimension, `["field-id", field-id]`
*/
export class FieldIDDimension extends FieldDimension {
static parseMBQL(mbql: ConcreteField, metadata?: ?Metadata) {
if (typeof mbql === "number") {
// DEPRECATED: bare field id
return new FieldIDDimension(null, [mbql], metadata);
} else if (Array.isArray(mbql) && mbqlEq(mbql[0], "field-id")) {
return new FieldIDDimension(null, mbql.slice(1), metadata);
}
return null;
}
mbql(): LocalFieldReference {
return ["field-id", this._args[0]];
}
field() {
return (this._metadata && this._metadata.fields[this._args[0]]) ||
new Field();
}
}
/**
* Foreign key-based dimension, `["fk->", fk-field-id, dest-field-id]`
*/
export class FKDimension extends FieldDimension {
static parseMBQL(mbql: ConcreteField, metadata?: ?Metadata): ?Dimension {
if (Array.isArray(mbql) && mbqlEq(mbql[0], "fk->")) {
// $FlowFixMe
const fkRef: ForeignFieldReference = mbql;
const parent = Dimension.parseMBQL(fkRef[1], metadata);
return new FKDimension(parent, fkRef.slice(2));
}
return null;
}
static dimensions(parent: Dimension): Dimension[] {
if (parent instanceof FieldDimension) {
const field = parent.field();
if (field.target && field.target.table) {
return field.target.table.fields.map(
field => new FKDimension(parent, [field.id])
);
}
}
return [];
}
mbql(): ForeignFieldReference {
// TODO: not sure `this._parent._args[0]` is the best way to handle this?
// we don't want the `["field-id", ...]` wrapper from the `this._parent.mbql()`
return ["fk->", this._parent._args[0], this._args[0]];
}
field() {
return (this._metadata && this._metadata.fields[this._args[0]]) ||
new Field();
}
render() {
return [
stripId(this._parent.field().display_name),
<Icon name="connections" className="px1" size={10} />,
this.field().display_name
];
}
}
import { DATETIME_UNITS, formatBucketing } from "metabase/lib/query_time";
const isFieldDimension = dimension =>
dimension instanceof FieldIDDimension || dimension instanceof FKDimension;
/**
* DatetimeField dimension, `["datetime-field", field-reference, datetime-unit]`
*/
export class DatetimeFieldDimension extends FieldDimension {
static parseMBQL(mbql: ConcreteField, metadata?: ?Metadata): ?Dimension {
if (Array.isArray(mbql) && mbqlEq(mbql[0], "datetime-field")) {
const parent = Dimension.parseMBQL(mbql[1], metadata);
// DEPRECATED: ["datetime-field", id, "of", unit]
if (mbql.length === 4) {
return new DatetimeFieldDimension(parent, mbql.slice(3));
} else {
return new DatetimeFieldDimension(parent, mbql.slice(2));
}
}
return null;
}
static dimensions(parent: Dimension): Dimension[] {
if (isFieldDimension(parent) && parent.field().isDate()) {
return DATETIME_UNITS.map(
unit => new DatetimeFieldDimension(parent, [unit])
);
}
return [];
}
static defaultDimension(parent: Dimension): ?Dimension {
if (isFieldDimension(parent) && parent.field().isDate()) {
return new DatetimeFieldDimension(parent, ["day"]);
}
return null;
}
mbql(): DatetimeField {
return ["datetime-field", this._parent.mbql(), this._args[0]];
}
baseDimension(): Dimension {
return this._parent.baseDimension();
}
bucketing(): DatetimeUnit {
return this._args[0];
}
subDisplayName(): string {
return formatBucketing(this._args[0]);
}
subTriggerDisplayName(): string {
return "by " + formatBucketing(this._args[0]).toLowerCase();
}
render() {
return [...super.render(), ": ", this.subDisplayName()];
}
}
/**
* Binned dimension, `["binning-strategy", field-reference, strategy, ...args]`
*/
export class BinnedDimension extends FieldDimension {
static parseMBQL(mbql: ConcreteField, metadata?: ?Metadata) {
if (Array.isArray(mbql) && mbqlEq(mbql[0], "binning-strategy")) {
const parent = Dimension.parseMBQL(mbql[1], metadata);
return new BinnedDimension(parent, mbql.slice(2));
}
return null;
}
static dimensions(parent: Dimension): Dimension[] {
if (isFieldDimension(parent) && parent.field().isNumber()) {
return [5, 10, 25, 100].map(
bins => new BinnedDimension(parent, ["default", bins])
);
}
return [];
}
mbql() {
return ["binning-strategy", this._parent.mbql(), ...this._args];
}
baseDimension(): Dimension {
return this._parent.baseDimension();
}
subDisplayName(): string {
if (this._args[0] === "default") {
return `Quantized into ${this._args[1]} ${inflect("bins", this._args[1])}`;
}
return JSON.stringify(this._args);
}
subTriggerDisplayName(): string {
if (this._args[0] === "default") {
return `${this._args[1]} ${inflect("bins", this._args[1])}`;
}
return "";
}
}
/**
* Expression reference, `["expression", expression-name]`
*/
export class ExpressionDimension extends Dimension {
tag = "Custom";
static parseMBQL(mbql: any, metadata?: ?Metadata): ?Dimension {
if (Array.isArray(mbql) && mbqlEq(mbql[0], "expression")) {
return new ExpressionDimension(null, mbql.slice(1));
}
}
mbql(): ExpressionReference {
return ["expression", this._args[0]];
}
displayName(): string {
return this._args[0];
}
icon(): IconName {
// TODO: eventually will need to get the type from the return type of the expression
return "int";
}
}
/**
* Aggregation reference, `["aggregation", aggregation-index]`
*/
export class AggregationDimension extends Dimension {
static parseMBQL(mbql: any, metadata?: ?Metadata): ?Dimension {
if (Array.isArray(mbql) && mbqlEq(mbql[0], "aggregation")) {
return new AggregationDimension(null, mbql.slice(1));
}
}
constructor(parent, args, metadata, displayName) {
super(parent, args, metadata);
this._displayName = displayName;
}
displayName(): string {
return this._displayName;
}
mbql() {
return ["aggregation", this._args[0]];
}
icon() {
return "int";
}
}
const DIMENSION_TYPES: typeof Dimension[] = [
FieldIDDimension,
FKDimension,
DatetimeFieldDimension,
ExpressionDimension,
BinnedDimension,
AggregationDimension
];
import Dimension from "./Dimension";
import {
metadata,
ORDERS_TOTAL_FIELD_ID,
PRODUCT_CATEGORY_FIELD_ID,
ORDERS_CREATED_DATE_FIELD_ID,
ORDERS_PRODUCT_FK_FIELD_ID,
PRODUCT_TILE_FIELD_ID
} from "metabase/__support__/sample_dataset_fixture";
describe("Dimension", () => {
describe("STATIC METHODS", () => {
describe("parseMBQL(mbql metadata)", () => {
it("parses and format MBQL correctly", () => {
expect(Dimension.parseMBQL(1, metadata).mbql()).toEqual([
"field-id",
1
]);
expect(
Dimension.parseMBQL(["field-id", 1], metadata).mbql()
).toEqual(["field-id", 1]);
expect(
Dimension.parseMBQL(["fk->", 1, 2], metadata).mbql()
).toEqual(["fk->", 1, 2]);
expect(
Dimension.parseMBQL(
["datetime-field", 1, "month"],
metadata
).mbql()
).toEqual(["datetime-field", ["field-id", 1], "month"]);
expect(
Dimension.parseMBQL(
["datetime-field", ["field-id", 1], "month"],
metadata
).mbql()
).toEqual(["datetime-field", ["field-id", 1], "month"]);
expect(
Dimension.parseMBQL(
["datetime-field", ["fk->", 1, 2], "month"],
metadata
).mbql()
).toEqual(["datetime-field", ["fk->", 1, 2], "month"]);
});
});
describe("isEqual(other)", () => {
it("returns true for equivalent field-ids", () => {
const d1 = Dimension.parseMBQL(1, metadata);
const d2 = Dimension.parseMBQL(["field-id", 1], metadata);
expect(d1.isEqual(d2)).toEqual(true);
expect(d1.isEqual(["field-id", 1])).toEqual(true);
expect(d1.isEqual(1)).toEqual(true);
});
it("returns false for different type clauses", () => {
const d1 = Dimension.parseMBQL(["fk->", 1, 2], metadata);
const d2 = Dimension.parseMBQL(["field-id", 1], metadata);
expect(d1.isEqual(d2)).toEqual(false);
});
it("returns false for same type clauses with different arguments", () => {
const d1 = Dimension.parseMBQL(["fk->", 1, 2], metadata);
const d2 = Dimension.parseMBQL(["fk->", 1, 3], metadata);
expect(d1.isEqual(d2)).toEqual(false);
});
});
});
describe("INSTANCE METHODS", () => {
describe("dimensions()", () => {
it("returns `dimension_options` of the underlying field if available", () => {
pending();
});
it("returns sub-dimensions for matching dimension if no `dimension_options`", () => {
// just a single scenario should be sufficient here as we will test
// `static dimensions()` individually for each dimension
pending();
});
});
describe("isSameBaseDimension(other)", () => {
it("returns true if the base dimensions are same", () => {
pending();
});
it("returns false if the base dimensions don't match", () => {
pending();
});
});
});
describe("INSTANCE METHODS", () => {
describe("dimensions()", () => {
it("returns `default_dimension_option` of the underlying field if available", () => {
pending();
});
it("returns default dimension for matching dimension if no `default_dimension_option`", () => {
// just a single scenario should be sufficient here as we will test
// `static defaultDimension()` individually for each dimension
pending();
});
});
});
});
describe("FieldIDDimension", () => {
const dimension = Dimension.parseMBQL(
["field-id", ORDERS_TOTAL_FIELD_ID],
metadata
);
describe("INSTANCE METHODS", () => {
describe("mbql()", () => {
it('returns a "field-id" clause', () => {
expect(dimension.mbql()).toEqual([
"field-id",
ORDERS_TOTAL_FIELD_ID
]);
});
});
describe("displayName()", () => {
it("returns the field name", () => {
expect(dimension.displayName()).toEqual("Total");
});
});
describe("subDisplayName()", () => {
it("returns 'Continuous (no binning)' for numeric fields", () => {
expect(dimension.subDisplayName()).toEqual(
"Continuous (no binning)"
);
});
it("returns 'Default' for non-numeric fields", () => {
expect(
Dimension.parseMBQL(
["field-id", PRODUCT_CATEGORY_FIELD_ID],
metadata
).subDisplayName()
).toEqual("Default");
});
});
describe("subTriggerDisplayName()", () => {
it("does not have a value", () => {
expect(dimension.subTriggerDisplayName()).toBeFalsy();
});
});
});
});
describe("FKDimension", () => {
const dimension = Dimension.parseMBQL(
["fk->", ORDERS_PRODUCT_FK_FIELD_ID, PRODUCT_TILE_FIELD_ID],
metadata
);
describe("STATIC METHODS", () => {
describe("dimensions(parentDimension)", () => {
it("should return array of FK dimensions for foreign key field dimension", () => {
pending();
// Something like this:
// fieldsInProductsTable = metadata.tables[1].fields.length;
// expect(FKDimension.dimensions(fkFieldIdDimension).length).toEqual(fieldsInProductsTable);
});
it("should return empty array for non-FK field dimension", () => {
pending();
});
});
});
describe("INSTANCE METHODS", () => {
describe("mbql()", () => {
it('returns a "fk->" clause', () => {
expect(dimension.mbql()).toEqual([
"fk->",
ORDERS_PRODUCT_FK_FIELD_ID,
PRODUCT_TILE_FIELD_ID
]);
});
});
describe("displayName()", () => {
it("returns the field name", () => {
expect(dimension.displayName()).toEqual("Title");
});
});
describe("subDisplayName()", () => {
it("returns the field name", () => {
expect(dimension.subDisplayName()).toEqual("Title");
});
});
describe("subTriggerDisplayName()", () => {
it("does not have a value", () => {
expect(dimension.subTriggerDisplayName()).toBeFalsy();
});
});
});
});
describe("DatetimeFieldDimension", () => {
const dimension = Dimension.parseMBQL(
["datetime-field", ORDERS_CREATED_DATE_FIELD_ID, "month"],
metadata
);
describe("STATIC METHODS", () => {
describe("dimensions(parentDimension)", () => {
it("should return an array with dimensions for each datetime unit", () => {
pending();
// Something like this:
// fieldsInProductsTable = metadata.tables[1].fields.length;
// expect(FKDimension.dimensions(fkFieldIdDimension).length).toEqual(fieldsInProductsTable);
});
it("should return empty array for non-date field dimension", () => {
pending();
});
});
describe("defaultDimension(parentDimension)", () => {
it("should return dimension with 'day' datetime unit", () => {
pending();
});
it("should return null for non-date field dimension", () => {
pending();
});
});
});
describe("INSTANCE METHODS", () => {
describe("mbql()", () => {
it('returns a "datetime-field" clause', () => {
expect(dimension.mbql()).toEqual([
"datetime-field",
["field-id", ORDERS_CREATED_DATE_FIELD_ID],
"month"
]);
});
});
describe("displayName()", () => {
it("returns the field name", () => {
expect(dimension.displayName()).toEqual("Created At");
});
});
describe("subDisplayName()", () => {
it("returns 'Month'", () => {
expect(dimension.subDisplayName()).toEqual("Month");
});
});
describe("subTriggerDisplayName()", () => {
it("returns 'by month'", () => {
expect(dimension.subTriggerDisplayName()).toEqual("by month");
});
});
});
});
describe("BinningStrategyDimension", () => {
const dimension = Dimension.parseMBQL(
["binning-strategy", ORDERS_TOTAL_FIELD_ID, "default", 10],
metadata
);
describe("STATIC METHODS", () => {
describe("dimensions(parentDimension)", () => {
it("should return an array of dimensions based on default binning", () => {
pending();
});
it("should return empty array for non-number field dimension", () => {
pending();
});
});
});
describe("INSTANCE METHODS", () => {
describe("mbql()", () => {
it('returns a "binning-strategy" clause', () => {
expect(dimension.mbql()).toEqual([
"binning-strategy",
["field-id", ORDERS_TOTAL_FIELD_ID],
"default",
10
]);
});
});
describe("displayName()", () => {
it("returns the field name", () => {
expect(dimension.displayName()).toEqual("Total");
});
});
describe("subDisplayName()", () => {
it("returns 'Quantized into 10 bins'", () => {
expect(dimension.subDisplayName()).toEqual(
"Quantized into 10 bins"
);
});
});
describe("subTriggerDisplayName()", () => {
it("returns '10 bins'", () => {
expect(dimension.subTriggerDisplayName()).toEqual("10 bins");
});
});
});
});
describe("ExpressionDimension", () => {
const dimension = Dimension.parseMBQL(
["expression", "Hello World"],
metadata
);
describe("STATIC METHODS", () => {
describe("dimensions(parentDimension)", () => {
it("should return array of FK dimensions for foreign key field dimension", () => {
pending();
// Something like this:
// fieldsInProductsTable = metadata.tables[1].fields.length;
// expect(FKDimension.dimensions(fkFieldIdDimension).length).toEqual(fieldsInProductsTable);
});
it("should return empty array for non-FK field dimension", () => {
pending();
});
});
});
describe("INSTANCE METHODS", () => {
describe("mbql()", () => {
it('returns an "expression" clause', () => {
expect(dimension.mbql()).toEqual(["expression", "Hello World"]);
});
});
describe("displayName()", () => {
it("returns the expression name", () => {
expect(dimension.displayName()).toEqual("Hello World");
});
});
});
});
describe("AggregationDimension", () => {
const dimension = Dimension.parseMBQL(["aggregation", 1], metadata);
describe("INSTANCE METHODS", () => {
describe("mbql()", () => {
it('returns an "aggregation" clause', () => {
expect(dimension.mbql()).toEqual(["aggregation", 1]);
});
});
});
});
import _ from "underscore";
import Question from "metabase-lib/lib/Question";
import { getMode } from "metabase/qb/lib/modes";
import type {
ClickAction,
ClickObject,
QueryMode
} from "metabase/meta/types/Visualization";
export default class Mode {
_question: Question;
_queryMode: QueryMode;
constructor(question: Question, queryMode: QueryMode) {
this._question = question;
this._queryMode = queryMode;
}
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);
if (queryMode) {
return new Mode(question, queryMode);
} else {
return null;
}
}
queryMode() {
return this._queryMode;
}
name() {
return this._queryMode.name;
}
actions(): ClickAction[] {
return _.flatten(
this._queryMode.actions.map(actionCreator =>
actionCreator({ question: this._question }))
);
}
actionsForClick(clicked: ?ClickObject): ClickAction[] {
return _.flatten(
this._queryMode.drills.map(actionCreator =>
actionCreator({ question: this._question, clicked }))
);
}
}
import {
metadata,
DATABASE_ID,
ORDERS_TABLE_ID,
orders_raw_card
} from "metabase/__support__/sample_dataset_fixture";
import Question from "./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();
describe("forQuestion(question)", () => {
it("with structured query question", () => {
// testbed for generative testing? see http://leebyron.com/testcheck-js
it("returns `segment` mode with raw data", () => {});
it("returns `metric` mode with >= 1 aggregations", () => {});
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", () => {});
it("returns `pivot` mode with >=1 aggregations and 1-2 category breakouts", () => {});
it("returns `default` mode with >=0 aggregations and >=3 breakouts", () => {});
it("returns `default` mode with >=1 aggregations and >=1 breakouts when first neither date or category", () => {});
});
it("with native query question", () => {
it("returns `NativeMode` for empty query", () => {});
it("returns `NativeMode` for query with query text", () => {});
});
it("with oddly constructed query", () => {
it("should throw an error", () => {
// this is not the actual behavior atm (it returns DefaultMode)
});
});
});
describe("name()", () => {
it("returns the correct name of current mode", () => {});
});
describe("actions()", () => {
describe("for a new question with Orders table and Raw data aggregation", () => {
pending();
it("returns a correct number of mode actions", () => {
expect(rawDataQuestionMode.actions().length).toBe(3);
});
it("returns a defined metric as mode action 1", () => {
expect(rawDataQuestionMode.actions()[0].name).toBe(
"common-metric"
);
// TODO: Sameer 6/16/17
// This is wack and not really testable. We shouldn't be passing around react components in this imo
// expect(question.actions()[1].title.props.children).toBe("Total Order Value");
});
it("returns a count timeseries as mode action 2", () => {
expect(rawDataQuestionMode.actions()[1].name).toBe(
"count-by-time"
);
expect(rawDataQuestionMode.actions()[1].icon).toBe("line");
// TODO: Sameer 6/16/17
// This is wack and not really testable. We shouldn't be passing around react components in this imo
// expect(question.actions()[2].title.props.children).toBe("Count of rows by time");
});
it("returns summarize as mode action 3", () => {
expect(rawDataQuestionMode.actions()[2].name).toBe("summarize");
expect(rawDataQuestionMode.actions()[2].icon).toBe("sum");
expect(rawDataQuestionMode.actions()[2].title).toBe(
"Summarize this segment"
);
});
});
describe("for a question with an aggregation and a time breakout", () => {
it("has pivot as mode actions 1 and 2", () => {
expect(timeBreakoutQuestionMode.actions().length).toBe(3);
expect(timeBreakoutQuestionMode.actions()[0].name).toBe(
"pivot-by-category"
);
expect(timeBreakoutQuestionMode.actions()[1].name).toBe(
"pivot-by-location"
);
});
});
});
describe("actionsForClick()", () => {
// this is action-specific so just rudimentary tests here showing that the actionsForClick logic works
// Action-specific tests would optimally be in their respective test files
});
});
/* @flow weak */
export default class Parameter {}
import {
DATABASE_ID,
ORDERS_TABLE_ID,
metadata
} from "metabase/__support__/sample_dataset_fixture";
import Question from "metabase-lib/lib/Question";
import { login } from "metabase/__support__/integrated_tests";
import { NATIVE_QUERY_TEMPLATE } from "metabase-lib/lib/queries/NativeQuery";
// TODO Atte Keinänen 6/22/17: This could include tests that run each "question drill action" (summarize etc)
// and check that the result is correct
describe("Question", () => {
beforeAll(async () => {
await login();
});
describe("with SQL questions", () => {
it("should return correct result with a static template tag parameter", async () => {
const templateTagName = "orderid";
const templateTagId = "f1cb12ed3-8727-41b6-bbb4-b7ba31884c30";
const question = Question.create({
databaseId: DATABASE_ID,
tableId: ORDERS_TABLE_ID,
metadata
}).setDatasetQuery({
...NATIVE_QUERY_TEMPLATE,
database: DATABASE_ID,
native: {
query: `SELECT SUBTOTAL FROM ORDERS WHERE id = {{${templateTagName}}}`,
template_tags: {
[templateTagName]: {
id: templateTagId,
name: templateTagName,
display_name: "Order ID",
type: "number"
}
}
}
});
// Without a template tag the query should fail
const results1 = await question.getResults({ ignoreCache: true });
expect(results1[0].status).toBe("failed");
question._parameterValues = { [templateTagId]: "5" };
const results2 = await question.getResults({ ignoreCache: true });
expect(results2[0]).toBeDefined();
expect(results2[0].data.rows[0][0]).toEqual(18.1);
});
it("should return correct result with an optional template tag clause", async () => {
const templateTagName = "orderid";
const templateTagId = "f1cb12ed3-8727-41b6-bbb4-b7ba31884c30";
const question = Question.create({
databaseId: DATABASE_ID,
tableId: ORDERS_TABLE_ID,
metadata
}).setDatasetQuery({
...NATIVE_QUERY_TEMPLATE,
database: DATABASE_ID,
native: {
query: `SELECT SUBTOTAL FROM ORDERS [[WHERE id = {{${templateTagName}}}]]`,
template_tags: {
[templateTagName]: {
id: templateTagId,
name: templateTagName,
display_name: "Order ID",
type: "number"
}
}
}
});
const results1 = await question.getResults({ ignoreCache: true });
expect(results1[0]).toBeDefined();
expect(results1[0].data.rows.length).toEqual(10000);
question._parameterValues = { [templateTagId]: "5" };
const results2 = await question.getResults({ ignoreCache: true });
expect(results2[0]).toBeDefined();
expect(results2[0].data.rows[0][0]).toEqual(18.1);
});
});
});
/* @flow weak */
import Query from "./queries/Query";
import Metadata from "./metadata/Metadata";
import Table from "./metadata/Table";
import Field from "./metadata/Field";
import StructuredQuery, {
STRUCTURED_QUERY_TEMPLATE
} from "./queries/StructuredQuery";
import NativeQuery from "./queries/NativeQuery";
import { memoize } from "metabase-lib/lib/utils";
import Utils from "metabase/lib/utils";
import * as Card_DEPRECATED from "metabase/lib/card";
import Query_DEPRECATED from "metabase/lib/query";
import { getParametersWithExtras } from "metabase/meta/Card";
import {
summarize,
pivot,
filter,
breakout,
toUnderlyingRecords,
drillUnderlyingRecords
} from "metabase/qb/lib/actions";
import _ from "underscore";
import { chain, assoc } from "icepick";
import type {
Parameter as ParameterObject,
ParameterValues
} from "metabase/meta/types/Parameter";
import type {
DatasetQuery,
Card as CardObject
} from "metabase/meta/types/Card";
import { MetabaseApi, CardApi } from "metabase/services";
import AtomicQuery from "metabase-lib/lib/queries/AtomicQuery";
import type { Dataset } from "metabase/meta/types/Dataset";
import type { TableId } from "metabase/meta/types/Table";
import type { DatabaseId } from "metabase/meta/types/Database";
import * as Urls from "metabase/lib/urls";
import Mode from "metabase-lib/lib/Mode";
/**
* This is a wrapper around a question/card object, which may contain one or more Query objects
*/
export default class Question {
/**
* The Question wrapper requires a metadata object because the queries it contains (like {@link StructuredQuery))
* need metadata for accessing databases, tables and metrics.
*/
_metadata: Metadata;
/**
* The plain object presentation of this question, equal to the format that Metabase REST API understands.
* It is called `card` for both historical reasons and to make a clear distinction to this class.
*/
_card: CardObject;
/**
* Parameter values mean either the current values of dashboard filters or SQL editor template parameters.
* They are in the grey area between UI state and question state, but having them in Question wrapper is convenient.
*/
_parameterValues: ParameterValues;
/**
* Question constructor
*/
constructor(
metadata: Metadata,
card: CardObject,
parameterValues?: ParameterValues
) {
this._metadata = metadata;
this._card = card;
this._parameterValues = parameterValues || {};
}
/**
* TODO Atte Keinänen 6/13/17: Discussed with Tom that we could use the default Question constructor instead,
* but it would require changing the constructor signature so that `card` is an optional parameter and has a default value
*/
static create(
{
databaseId,
tableId,
metadata,
parameterValues,
...cardProps
}: {
databaseId?: DatabaseId,
tableId?: TableId,
metadata: Metadata,
parameterValues?: ParameterValues
}
) {
// $FlowFixMe
const card: Card = {
name: cardProps.name || null,
display: cardProps.display || "table",
visualization_settings: cardProps.visualization_settings || {},
dataset_query: STRUCTURED_QUERY_TEMPLATE // temporary placeholder
};
const initialQuestion = new Question(metadata, card, parameterValues);
const query = StructuredQuery.newStucturedQuery({
question: initialQuestion,
databaseId,
tableId
});
return initialQuestion.setQuery(query);
}
metadata(): Metadata {
return this._metadata;
}
card() {
return this._card;
}
setCard(card: CardObject): Question {
return new Question(this._metadata, card, this._parameterValues);
}
withoutNameAndId() {
return this.setCard(
chain(this.card())
.dissoc("id")
.dissoc("name")
.dissoc("description")
.value()
);
}
/**
* A question contains either a:
* - StructuredQuery for queries written in MBQL
* - NativeQuery for queries written in data source's native query language
*
* This is just a wrapper object, the data is stored in `this._card.dataset_query` in a format specific to the query type.
*/
@memoize query(): Query {
const datasetQuery = this._card.dataset_query;
for (const QueryClass of [StructuredQuery, NativeQuery]) {
if (QueryClass.isDatasetQueryType(datasetQuery)) {
return new QueryClass(this, datasetQuery);
}
}
throw new Error("Unknown query type: " + datasetQuery.type);
}
/**
* Returns a new Question object with an updated query.
* The query is saved to the `dataset_query` field of the Card object.
*/
setQuery(newQuery: Query): Question {
if (this._card.dataset_query !== newQuery.datasetQuery()) {
return this.setCard(
assoc(this.card(), "dataset_query", newQuery.datasetQuery())
);
}
return this;
}
setDatasetQuery(newDatasetQuery: DatasetQuery): Question {
return this.setCard(
assoc(this.card(), "dataset_query", newDatasetQuery)
);
}
/**
* Returns a list of atomic queries (NativeQuery or StructuredQuery) contained in this question
*/
atomicQueries(): AtomicQuery[] {
const query = this.query();
if (query instanceof AtomicQuery) return [query];
return [];
}
/**
* The visualization type of the question
*/
display(): string {
return this._card && this._card.display;
}
setDisplay(display) {
return this.setCard(assoc(this.card(), "display", display));
}
isEmpty(): boolean {
return this.query().isEmpty();
}
/**
* Question is valid (as far as we know) and can be executed
*/
canRun(): boolean {
return this.query().canRun();
}
canWrite(): boolean {
return this._card && this._card.can_write;
}
/**
* Visualization drill-through and action widget actions
*
* Although most of these are essentially a way to modify the current query, having them as a part
* of Question interface instead of Query interface makes it more convenient to also change the current visualization
*/
summarize(aggregation) {
const tableMetadata = this.tableMetadata();
return this.setCard(summarize(this.card(), aggregation, tableMetadata));
}
breakout(b) {
return this.setCard(breakout(this.card(), b));
}
pivot(breakout, dimensions = []) {
const tableMetadata = this.tableMetadata();
return this.setCard(
// $FlowFixMe: tableMetadata could be null
pivot(this.card(), breakout, tableMetadata, dimensions)
);
}
filter(operator, column, value) {
return this.setCard(filter(this.card(), operator, column, value));
}
drillUnderlyingRecords(dimensions) {
return this.setCard(drillUnderlyingRecords(this.card(), dimensions));
}
toUnderlyingRecords(): ?Question {
const newCard = toUnderlyingRecords(this.card());
if (newCard) {
return this.setCard(newCard);
}
}
toUnderlyingData(): Question {
return this.setDisplay("table");
}
drillPK(field: Field, value: Value): ?Question {
const query = this.query();
if (query instanceof StructuredQuery) {
return query
.reset()
.setTable(field.table)
.addFilter(["=", ["field-id", field.id], value])
.question();
}
}
// deprecated
tableMetadata(): ?Table {
const query = this.query();
if (query instanceof StructuredQuery) {
return query.table();
} else {
return null;
}
}
mode(): ?Mode {
return Mode.forQuestion(this);
}
/**
* A user-defined name for the question
*/
displayName(): ?string {
return this._card && this._card.name;
}
setDisplayName(name: String) {
return this.setCard(assoc(this.card(), "name", name));
}
id(): number {
return this._card && this._card.id;
}
isSaved(): boolean {
return !!this.id();
}
publicUUID(): string {
return this._card && this._card.public_uuid;
}
getUrl(originalQuestion?: Question): string {
const isDirty = !originalQuestion ||
this.isDirtyComparedTo(originalQuestion);
return isDirty
? Urls.question(null, this._serializeForUrl())
: Urls.question(this.id(), "");
}
/**
* Runs the query and returns an array containing results for each single query.
*
* If we have a saved and clean single-query question, we use `CardApi.query` instead of a ad-hoc dataset query.
* This way we benefit from caching and query optimizations done by Metabase backend.
*/
async getResults(
{ cancelDeferred, isDirty = false, ignoreCache = false } = {}
): Promise<[Dataset]> {
// TODO Atte Keinänen 7/5/17: Should we clean this query with Query.cleanQuery(query) before executing it?
const canUseCardApiEndpoint = !isDirty && this.isSaved();
const parameters = this.parametersList()
// include only parameters that have a value applied
.filter(param => _.has(param, "value"))
// only the superset of parameters object that API expects
.map(param => _.pick(param, "type", "target", "value"));
if (canUseCardApiEndpoint) {
const queryParams = {
cardId: this.id(),
ignore_cache: ignoreCache,
parameters
};
return [
await CardApi.query(queryParams, {
cancelled: cancelDeferred.promise
})
];
} else {
const getDatasetQueryResult = datasetQuery => {
const datasetQueryWithParameters = {
...datasetQuery,
parameters
};
return MetabaseApi.dataset(
datasetQueryWithParameters,
cancelDeferred ? { cancelled: cancelDeferred.promise } : {}
);
};
const datasetQueries = this.atomicQueries().map(query =>
query.datasetQuery());
return Promise.all(datasetQueries.map(getDatasetQueryResult));
}
}
// TODO: Fix incorrect Flow signature
parameters(): ParameterObject[] {
return getParametersWithExtras(this.card(), this._parameterValues);
}
parametersList(): ParameterObject[] {
// $FlowFixMe
return (Object.values(this.parameters()): ParameterObject[]);
}
// predicate function that dermines if the question is "dirty" compared to the given question
isDirtyComparedTo(originalQuestion: Question) {
// TODO Atte Keinänen 6/8/17: Reconsider these rules because they don't completely match
// the current implementation which uses original_card_id for indicating that question has a lineage
// The rules:
// - if it's new, then it's dirty when
// 1) there is a database/table chosen or
// 2) when there is any content on the native query
// - if it's saved, then it's dirty when
// 1) the current card doesn't match the last saved version
if (!this._card) {
return false;
} else if (!this._card.id) {
if (
this._card.dataset_query.query &&
this._card.dataset_query.query.source_table
) {
return true;
} else if (
this._card.dataset_query.type === "native" &&
!_.isEmpty(this._card.dataset_query.native.query)
) {
return true;
} else {
return false;
}
} else {
const origCardSerialized = originalQuestion._serializeForUrl({
includeOriginalCardId: false
});
const currentCardSerialized = this._serializeForUrl({
includeOriginalCardId: false
});
return currentCardSerialized !== origCardSerialized;
}
}
// Internal methods
_serializeForUrl({ includeOriginalCardId = true } = {}) {
// TODO Atte Keinänen 5/31/17: Remove code mutation and unnecessary copying
const dataset_query = Utils.copy(this._card.dataset_query);
if (dataset_query.query) {
dataset_query.query = Query_DEPRECATED.cleanQuery(
dataset_query.query
);
}
const cardCopy = {
name: this._card.name,
description: this._card.description,
dataset_query: dataset_query,
display: this._card.display,
parameters: this._card.parameters,
visualization_settings: this._card.visualization_settings,
...(includeOriginalCardId
? { original_card_id: this._card.original_card_id }
: {})
};
return Card_DEPRECATED.utf8_to_b64url(JSON.stringify(cardCopy));
}
}
This diff is collapsed.
/* @flow weak */
/**
* Not in use yet, the series transformation functions could live here
*/
export class QuestionResult {}
import Base from "metabase-lib/lib/metadata/Base";
import AggregationWrapper from "metabase-lib/lib/queries/Aggregation";
import type { Field } from "metabase/meta/types/Field";
/**
* Wrapper class for an aggregation object
*/
export default class AggregationOption extends Base {
name: string;
short: string;
// TODO: Now just a plain object; wrap to a Field wrapper class
fields: Field[];
validFieldsFilters: [(fields: Field[]) => Field[]];
/**
* Aggregation has one or more required fields
*/
hasFields(): boolean {
return this.validFieldsFilters.length > 0;
}
toAggregation(): AggregationWrapper {
return new AggregationWrapper(
null,
[this.short].concat(this.fields.map(field => null))
);
}
}
export default class Base {
constructor(object = {}) {
for (const property in object) {
this[property] = object[property];
}
}
}
/* @flow weak */
import Question from "../Question";
import Base from "./Base";
import Table from "./Table";
import Schema from "./Schema";
import _ from "underscore";
import type { SchemaName } from "metabase/meta/types/Table";
/**
* Wrapper class for database metadata objects. Contains {@link Schema}s, {@link Table}s, {@link Metric}s, {@link Segment}s.
*
* Backed by types/Database data structure which matches the backend API contract
*/
export default class Database extends Base {
// TODO Atte Keinänen 6/11/17: List all fields here (currently only in types/Database)
displayName: string;
description: ?string;
tables: Table[];
schemas: Schema[];
tablesInSchema(schemaName: ?SchemaName) {
return this.tables.filter(table => table.schema === schemaName);
}
schemaNames(): Array<SchemaName> {
return _.uniq(
this.tables
.map(table => table.schema)
.filter(schemaName => schemaName != null)
);
}
newQuestion(): Question {
// $FlowFixMe
return new Question();
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment