From 0ef3cbe6edc68e2596c20afef3763fb842cc9372 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Thu, 8 Jul 2021 10:01:50 -0400 Subject: [PATCH] [Expressions] Introduce createTable expression function, and use in Lens (#103788) * [Expressions] Introduce createTable expression function, and use in Lens * Fix test * Fix code style * Fix typo Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../canvas/canvas-function-reference.asciidoc | 54 ++++++++++++ .../specs/create_table.ts | 84 +++++++++++++++++++ .../expression_functions/specs/index.ts | 1 + .../specs/tests/create_table.test.ts | 66 +++++++++++++++ .../common/service/expressions_services.ts | 2 + .../indexpattern.test.ts | 18 +++- .../definitions/formula/formula.test.tsx | 41 +++++++++ .../definitions/formula/formula.tsx | 4 +- .../operations/definitions/formula/parse.ts | 2 +- .../indexpattern_datasource/to_expression.ts | 24 +++++- .../metric_visualization/expression.tsx | 5 +- x-pack/test/functional/apps/lens/formula.ts | 20 +++++ 12 files changed, 311 insertions(+), 10 deletions(-) create mode 100644 src/plugins/expressions/common/expression_functions/specs/create_table.ts create mode 100644 src/plugins/expressions/common/expression_functions/specs/tests/create_table.test.ts diff --git a/docs/canvas/canvas-function-reference.asciidoc b/docs/canvas/canvas-function-reference.asciidoc index ac7cbba6e9933..7f84c06752f45 100644 --- a/docs/canvas/canvas-function-reference.asciidoc +++ b/docs/canvas/canvas-function-reference.asciidoc @@ -406,6 +406,60 @@ This prints the `datatable` objects in the browser console before and after the *Returns:* `any` +[float] +[[createTable_fn]] +=== `createTable` + +Creates a datatable with a list of columns, and 1 or more empty rows. +To populate the rows, use <> or <>. + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|ids *** † + +|`string` +|Column ids to generate in positional order. ID represents the key in the row. + +|`names` † +|`string` +|Column names to generate in positional order. Names are not required to be unique, and default to the ID if not provided. + +|`rowCount` + +Default: 1 +|`number` +|The number of empty rows to add to the table, to be assigned a value later. +|=== + +*Expression syntax* +[source,js] +---- +createTable id="a" id="b" +createTable id="a" name="A" id="b" name="B" rowCount=5 +---- + +*Code example* +[source,text] +---- +var_set + name="logs" value={essql "select count(*) as a from kibana_sample_data_logs"} + name="commerce" value={essql "select count(*) as b from kibana_sample_data_ecommerce"} +| createTable ids="totalA" ids="totalB" +| staticColumn name="totalA" value={var "logs" | getCell "a"} +| alterColumn column="totalA" type="number" +| staticColumn name="totalB" value={var "commerce" | getCell "b"} +| alterColumn column="totalB" type="number" +| mathColumn id="percent" name="percent" expression="totalA / totalB" +| render +---- + +This creates a table based on the results of two `essql` queries, joined +into one table. + +*Accepts:* `null` + [float] [[columns_fn]] diff --git a/src/plugins/expressions/common/expression_functions/specs/create_table.ts b/src/plugins/expressions/common/expression_functions/specs/create_table.ts new file mode 100644 index 0000000000000..5174b258a4d9b --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/create_table.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../types'; +import { Datatable, DatatableColumn } from '../../expression_types'; + +export interface CreateTableArguments { + ids: string[]; + names: string[] | null; + rowCount: number; +} + +export const createTable: ExpressionFunctionDefinition< + 'createTable', + null, + CreateTableArguments, + Datatable +> = { + name: 'createTable', + type: 'datatable', + inputTypes: ['null'], + help: i18n.translate('expressions.functions.createTableHelpText', { + defaultMessage: + 'Creates a datatable with a list of columns, and 1 or more empty rows. ' + + 'To populate the rows, use {mapColumnFn} or {mathColumnFn}.', + values: { + mathColumnFn: '`mathColumn`', + mapColumnFn: '`mapColumn`', + }, + }), + args: { + ids: { + types: ['string'], + help: i18n.translate('expressions.functions.createTable.args.idsHelpText', { + defaultMessage: + 'Column ids to generate in positional order. ID represents the key in the row.', + }), + required: false, + multi: true, + }, + names: { + types: ['string'], + help: i18n.translate('expressions.functions.createTable.args.nameHelpText', { + defaultMessage: + 'Column names to generate in positional order. Names are not required to be unique, and default to the ID if not provided.', + }), + required: false, + multi: true, + }, + rowCount: { + types: ['number'], + help: i18n.translate('expressions.functions.createTable.args.rowCountText', { + defaultMessage: + 'The number of empty rows to add to the table, to be assigned a value later', + }), + default: 1, + required: false, + }, + }, + fn(input, args) { + const columns: DatatableColumn[] = []; + + (args.ids ?? []).map((id, index) => { + columns.push({ + id, + name: args.names?.[index] ?? id, + meta: { type: 'null' }, + }); + }); + + return { + columns, + // Each row gets a unique object + rows: [...Array(args.rowCount)].map(() => ({})), + type: 'datatable', + }; + }, +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/index.ts b/src/plugins/expressions/common/expression_functions/specs/index.ts index e808021f75180..de9a1dedd8248 100644 --- a/src/plugins/expressions/common/expression_functions/specs/index.ts +++ b/src/plugins/expressions/common/expression_functions/specs/index.ts @@ -7,6 +7,7 @@ */ export * from './clog'; +export * from './create_table'; export * from './font'; export * from './var_set'; export * from './var'; diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/create_table.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/create_table.test.ts new file mode 100644 index 0000000000000..102fe286592fa --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/tests/create_table.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { functionWrapper } from './utils'; +import { createTable } from '../create_table'; + +describe('clear', () => { + const fn = functionWrapper(createTable); + + it('returns a blank table', () => { + expect(fn(null, {})).toEqual({ + type: 'datatable', + columns: [], + rows: [{}], + }); + }); + + it('creates a table with default names', () => { + expect( + fn(null, { + ids: ['a', 'b'], + rowCount: 3, + }) + ).toEqual({ + type: 'datatable', + columns: [ + { id: 'a', name: 'a', meta: { type: 'null' } }, + { id: 'b', name: 'b', meta: { type: 'null' } }, + ], + rows: [{}, {}, {}], + }); + }); + + it('create a table with names that match by position', () => { + expect( + fn(null, { + ids: ['a', 'b'], + names: ['name'], + }) + ).toEqual({ + type: 'datatable', + columns: [ + { id: 'a', name: 'name', meta: { type: 'null' } }, + { id: 'b', name: 'b', meta: { type: 'null' } }, + ], + rows: [{}], + }); + }); + + it('does provides unique objects for each row', () => { + const table = fn(null, { + ids: ['a', 'b'], + rowCount: 2, + }); + + table.rows[0].a = 'z'; + table.rows[1].b = 5; + + expect(table.rows).toEqual([{ a: 'z' }, { b: 5 }]); + }); +}); diff --git a/src/plugins/expressions/common/service/expressions_services.ts b/src/plugins/expressions/common/service/expressions_services.ts index 9e569c66bbe16..ed798db6cc915 100644 --- a/src/plugins/expressions/common/service/expressions_services.ts +++ b/src/plugins/expressions/common/service/expressions_services.ts @@ -21,6 +21,7 @@ import { PersistableStateService, SerializableState } from '../../../kibana_util import { Adapters } from '../../../inspector/common/adapters'; import { clog, + createTable, font, variableSet, variable, @@ -335,6 +336,7 @@ export class ExpressionsService implements PersistableStateService { expect(indexPatternDatasource.toExpression(state, 'first')).toEqual(null); }); - it('should generate an empty expression when there is a formula without aggs', async () => { + it('should create a table when there is a formula without aggs', async () => { const queryBaseState: IndexPatternBaseState = { currentIndexPatternId: '1', layers: { @@ -311,7 +311,21 @@ describe('IndexPattern Data Source', () => { }, }; const state = enrichBaseState(queryBaseState); - expect(indexPatternDatasource.toExpression(state, 'first')).toEqual(null); + expect(indexPatternDatasource.toExpression(state, 'first')).toEqual({ + chain: [ + { + function: 'createTable', + type: 'function', + arguments: { ids: [], names: [], rowCount: [1] }, + }, + { + arguments: { expression: [''], id: ['col1'], name: ['Formula'] }, + function: 'mapColumn', + type: 'function', + }, + ], + type: 'expression', + }); }); it('should generate an expression for an aggregated query', async () => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx index 279e76b839548..19d91c1006cf0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx @@ -440,6 +440,47 @@ describe('formula', () => { }); }); + it('should create a valid formula expression for numeric literals', () => { + expect( + regenerateLayerFromAst( + '0', + layer, + 'col1', + currentColumn, + indexPattern, + operationDefinitionMap + ).newLayer + ).toEqual({ + ...layer, + columnOrder: ['col1X0', 'col1'], + columns: { + ...layer.columns, + col1: { + ...currentColumn, + label: '0', + references: ['col1X0'], + params: { + ...currentColumn.params, + formula: '0', + isFormulaBroken: false, + }, + }, + col1X0: { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: 'Part of 0', + operationType: 'math', + params: { + tinymathAst: 0, + }, + references: [], + scale: 'ratio', + }, + }, + }); + }); + it('returns no change but error if the formula cannot be parsed', () => { const formulas = [ '+', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.tsx index 994f7280c3286..1b79448d82742 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.tsx @@ -55,8 +55,8 @@ export const formulaOperation: OperationDefinition< const visibleOperationsMap = filterByVisibleOperation(operationDefinitionMap); const { root, error } = tryToParse(column.params.formula, visibleOperationsMap); - if (error || !root) { - return [error!.message]; + if (error || root == null) { + return error?.message ? [error.message] : []; } const errors = runASTValidation(root, layer, indexPattern, visibleOperationsMap); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts index cb1d0dc143efc..088c7e0de64f8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts @@ -35,7 +35,7 @@ function parseAndExtract( label?: string ) { const { root, error } = tryToParse(text, operationDefinitionMap); - if (error || !root) { + if (error || root == null) { return { extracted: [], isValid: false }; } // before extracting the data run the validation task and throw if invalid diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 49bec5f58c29c..b6f5c364e2d04 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -135,10 +135,6 @@ function getExpressionForLayer( } }); - if (esAggEntries.length === 0) { - // Return early if there are no aggs, for example if the user has an empty formula - return null; - } const idMap = esAggEntries.reduce((currentIdMap, [colId, column], index) => { const esAggsId = `col-${index}-${index}`; return { @@ -234,6 +230,26 @@ function getExpressionForLayer( } ); + if (esAggEntries.length === 0) { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'createTable', + arguments: { + ids: [], + names: [], + rowCount: [1], + }, + }, + ...expressions, + ...formatterOverrides, + ...timeScaleFunctions, + ], + }; + } + const allDateHistogramFields = Object.values(columns) .map((column) => column.operationType === dateHistogramOperation.type ? column.sourceField : null diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.tsx index 60d9d66bce995..e21fa08b97410 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.tsx @@ -127,8 +127,11 @@ export function MetricChart({ return ; } - const column = firstTable.columns.find(({ id }) => id === accessor)!; + const column = firstTable.columns.find(({ id }) => id === accessor); const row = firstTable.rows[0]; + if (!column || !row) { + return ; + } // NOTE: Cardinality and Sum never receives "null" as value, but always 0, even for empty dataset. // Mind falsy values here as 0! diff --git a/x-pack/test/functional/apps/lens/formula.ts b/x-pack/test/functional/apps/lens/formula.ts index 17061f6e37170..8b87db21a1ffe 100644 --- a/x-pack/test/functional/apps/lens/formula.ts +++ b/x-pack/test/functional/apps/lens/formula.ts @@ -236,5 +236,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'count()' ); }); + + it('should allow numeric only formulas', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_metrics > lns-empty-dimension', + operation: 'formula', + formula: `0`, + }); + + await PageObjects.lens.dragDimensionToDimension( + 'lnsDatatable_metrics > lns-dimensionTrigger', + 'lnsDatatable_metrics > lns-empty-dimension' + ); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('0'); + expect(await PageObjects.lens.getDatatableCellText(0, 1)).to.eql('0'); + }); }); }