Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Expressions] Introduce createTable expression function, and use in Lens #103788

Merged
merged 6 commits into from
Jul 8, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/canvas/canvas-function-reference.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<mapColumn_Fn>> or <<mathColumnFn>>.

[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]]
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
};
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

export * from './clog';
export * from './create_table';
export * from './font';
export * from './var_set';
export * from './var';
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { PersistableStateService, SerializableState } from '../../../kibana_util
import { Adapters } from '../../../inspector/common/adapters';
import {
clog,
createTable,
font,
variableSet,
variable,
Expand Down Expand Up @@ -335,6 +336,7 @@ export class ExpressionsService implements PersistableStateService<ExpressionAst
public setup(...args: unknown[]): ExpressionsServiceSetup {
for (const fn of [
clog,
createTable,
font,
variableSet,
variable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ describe('IndexPattern Data Source', () => {
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: {
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
'+',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 || typeof root === 'undefined' || root == null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: root == null is sufficient for both undefined and null

return error?.message ? [error.message] : [];
}

const errors = runASTValidation(root, layer, indexPattern, visibleOperationsMap);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function parseAndExtract(
label?: string
) {
const { root, error } = tryToParse(text, operationDefinitionMap);
if (error || !root) {
if (error || typeof root === 'undefined' || root == null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

return { extracted: [], isValid: false };
}
// before extracting the data run the validation task and throw if invalid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -240,6 +236,26 @@ function getExpressionForLayer(
)
.filter((field): field is string => Boolean(field));

if (esAggEntries.length === 0) {
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'createTable',
arguments: {
ids: [],
names: [],
rowCount: [1],
},
},
...expressions,
...formatterOverrides,
...timeScaleFunctions,
],
};
}

return {
type: 'expression',
chain: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,11 @@ export function MetricChart({
return <EmptyPlaceholder icon={LensIconChartMetric} />;
}

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 <EmptyPlaceholder icon={LensIconChartMetric} />;
}

// NOTE: Cardinality and Sum never receives "null" as value, but always 0, even for empty dataset.
// Mind falsy values here as 0!
Expand Down
Loading