Skip to content

Commit a057ff6

Browse files
kibanamachineWylie Conlon
andauthored
[Expressions] Introduce createTable expression function, and use in Lens (#103788) (#104879)
* [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> Co-authored-by: Wylie Conlon <william.conlon@elastic.co>
1 parent d3b7e9d commit a057ff6

12 files changed

Lines changed: 311 additions & 10 deletions

File tree

docs/canvas/canvas-function-reference.asciidoc

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,60 @@ This prints the `datatable` objects in the browser console before and after the
406406

407407
*Returns:* `any`
408408

409+
[float]
410+
[[createTable_fn]]
411+
=== `createTable`
412+
413+
Creates a datatable with a list of columns, and 1 or more empty rows.
414+
To populate the rows, use <<mapColumn_fn>> or <<mathColumn_fn>>.
415+
416+
[cols="3*^<"]
417+
|===
418+
|Argument |Type |Description
419+
420+
|ids *** †
421+
422+
|`string`
423+
|Column ids to generate in positional order. ID represents the key in the row.
424+
425+
|`names` †
426+
|`string`
427+
|Column names to generate in positional order. Names are not required to be unique, and default to the ID if not provided.
428+
429+
|`rowCount`
430+
431+
Default: 1
432+
|`number`
433+
|The number of empty rows to add to the table, to be assigned a value later.
434+
|===
435+
436+
*Expression syntax*
437+
[source,js]
438+
----
439+
createTable id="a" id="b"
440+
createTable id="a" name="A" id="b" name="B" rowCount=5
441+
----
442+
443+
*Code example*
444+
[source,text]
445+
----
446+
var_set
447+
name="logs" value={essql "select count(*) as a from kibana_sample_data_logs"}
448+
name="commerce" value={essql "select count(*) as b from kibana_sample_data_ecommerce"}
449+
| createTable ids="totalA" ids="totalB"
450+
| staticColumn name="totalA" value={var "logs" | getCell "a"}
451+
| alterColumn column="totalA" type="number"
452+
| staticColumn name="totalB" value={var "commerce" | getCell "b"}
453+
| alterColumn column="totalB" type="number"
454+
| mathColumn id="percent" name="percent" expression="totalA / totalB"
455+
| render
456+
----
457+
458+
This creates a table based on the results of two `essql` queries, joined
459+
into one table.
460+
461+
*Accepts:* `null`
462+
409463

410464
[float]
411465
[[columns_fn]]
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
import { i18n } from '@kbn/i18n';
10+
import { ExpressionFunctionDefinition } from '../types';
11+
import { Datatable, DatatableColumn } from '../../expression_types';
12+
13+
export interface CreateTableArguments {
14+
ids: string[];
15+
names: string[] | null;
16+
rowCount: number;
17+
}
18+
19+
export const createTable: ExpressionFunctionDefinition<
20+
'createTable',
21+
null,
22+
CreateTableArguments,
23+
Datatable
24+
> = {
25+
name: 'createTable',
26+
type: 'datatable',
27+
inputTypes: ['null'],
28+
help: i18n.translate('expressions.functions.createTableHelpText', {
29+
defaultMessage:
30+
'Creates a datatable with a list of columns, and 1 or more empty rows. ' +
31+
'To populate the rows, use {mapColumnFn} or {mathColumnFn}.',
32+
values: {
33+
mathColumnFn: '`mathColumn`',
34+
mapColumnFn: '`mapColumn`',
35+
},
36+
}),
37+
args: {
38+
ids: {
39+
types: ['string'],
40+
help: i18n.translate('expressions.functions.createTable.args.idsHelpText', {
41+
defaultMessage:
42+
'Column ids to generate in positional order. ID represents the key in the row.',
43+
}),
44+
required: false,
45+
multi: true,
46+
},
47+
names: {
48+
types: ['string'],
49+
help: i18n.translate('expressions.functions.createTable.args.nameHelpText', {
50+
defaultMessage:
51+
'Column names to generate in positional order. Names are not required to be unique, and default to the ID if not provided.',
52+
}),
53+
required: false,
54+
multi: true,
55+
},
56+
rowCount: {
57+
types: ['number'],
58+
help: i18n.translate('expressions.functions.createTable.args.rowCountText', {
59+
defaultMessage:
60+
'The number of empty rows to add to the table, to be assigned a value later',
61+
}),
62+
default: 1,
63+
required: false,
64+
},
65+
},
66+
fn(input, args) {
67+
const columns: DatatableColumn[] = [];
68+
69+
(args.ids ?? []).map((id, index) => {
70+
columns.push({
71+
id,
72+
name: args.names?.[index] ?? id,
73+
meta: { type: 'null' },
74+
});
75+
});
76+
77+
return {
78+
columns,
79+
// Each row gets a unique object
80+
rows: [...Array(args.rowCount)].map(() => ({})),
81+
type: 'datatable',
82+
};
83+
},
84+
};

src/plugins/expressions/common/expression_functions/specs/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
export * from './clog';
10+
export * from './create_table';
1011
export * from './font';
1112
export * from './var_set';
1213
export * from './var';
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
import { functionWrapper } from './utils';
10+
import { createTable } from '../create_table';
11+
12+
describe('clear', () => {
13+
const fn = functionWrapper(createTable);
14+
15+
it('returns a blank table', () => {
16+
expect(fn(null, {})).toEqual({
17+
type: 'datatable',
18+
columns: [],
19+
rows: [{}],
20+
});
21+
});
22+
23+
it('creates a table with default names', () => {
24+
expect(
25+
fn(null, {
26+
ids: ['a', 'b'],
27+
rowCount: 3,
28+
})
29+
).toEqual({
30+
type: 'datatable',
31+
columns: [
32+
{ id: 'a', name: 'a', meta: { type: 'null' } },
33+
{ id: 'b', name: 'b', meta: { type: 'null' } },
34+
],
35+
rows: [{}, {}, {}],
36+
});
37+
});
38+
39+
it('create a table with names that match by position', () => {
40+
expect(
41+
fn(null, {
42+
ids: ['a', 'b'],
43+
names: ['name'],
44+
})
45+
).toEqual({
46+
type: 'datatable',
47+
columns: [
48+
{ id: 'a', name: 'name', meta: { type: 'null' } },
49+
{ id: 'b', name: 'b', meta: { type: 'null' } },
50+
],
51+
rows: [{}],
52+
});
53+
});
54+
55+
it('does provides unique objects for each row', () => {
56+
const table = fn(null, {
57+
ids: ['a', 'b'],
58+
rowCount: 2,
59+
});
60+
61+
table.rows[0].a = 'z';
62+
table.rows[1].b = 5;
63+
64+
expect(table.rows).toEqual([{ a: 'z' }, { b: 5 }]);
65+
});
66+
});

src/plugins/expressions/common/service/expressions_services.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { PersistableStateService, SerializableState } from '../../../kibana_util
2121
import { Adapters } from '../../../inspector/common/adapters';
2222
import {
2323
clog,
24+
createTable,
2425
font,
2526
variableSet,
2627
variable,
@@ -335,6 +336,7 @@ export class ExpressionsService implements PersistableStateService<ExpressionAst
335336
public setup(...args: unknown[]): ExpressionsServiceSetup {
336337
for (const fn of [
337338
clog,
339+
createTable,
338340
font,
339341
variableSet,
340342
variable,

x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ describe('IndexPattern Data Source', () => {
290290
expect(indexPatternDatasource.toExpression(state, 'first')).toEqual(null);
291291
});
292292

293-
it('should generate an empty expression when there is a formula without aggs', async () => {
293+
it('should create a table when there is a formula without aggs', async () => {
294294
const queryBaseState: IndexPatternBaseState = {
295295
currentIndexPatternId: '1',
296296
layers: {
@@ -311,7 +311,21 @@ describe('IndexPattern Data Source', () => {
311311
},
312312
};
313313
const state = enrichBaseState(queryBaseState);
314-
expect(indexPatternDatasource.toExpression(state, 'first')).toEqual(null);
314+
expect(indexPatternDatasource.toExpression(state, 'first')).toEqual({
315+
chain: [
316+
{
317+
function: 'createTable',
318+
type: 'function',
319+
arguments: { ids: [], names: [], rowCount: [1] },
320+
},
321+
{
322+
arguments: { expression: [''], id: ['col1'], name: ['Formula'] },
323+
function: 'mapColumn',
324+
type: 'function',
325+
},
326+
],
327+
type: 'expression',
328+
});
315329
});
316330

317331
it('should generate an expression for an aggregated query', async () => {

x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,47 @@ describe('formula', () => {
440440
});
441441
});
442442

443+
it('should create a valid formula expression for numeric literals', () => {
444+
expect(
445+
regenerateLayerFromAst(
446+
'0',
447+
layer,
448+
'col1',
449+
currentColumn,
450+
indexPattern,
451+
operationDefinitionMap
452+
).newLayer
453+
).toEqual({
454+
...layer,
455+
columnOrder: ['col1X0', 'col1'],
456+
columns: {
457+
...layer.columns,
458+
col1: {
459+
...currentColumn,
460+
label: '0',
461+
references: ['col1X0'],
462+
params: {
463+
...currentColumn.params,
464+
formula: '0',
465+
isFormulaBroken: false,
466+
},
467+
},
468+
col1X0: {
469+
customLabel: true,
470+
dataType: 'number',
471+
isBucketed: false,
472+
label: 'Part of 0',
473+
operationType: 'math',
474+
params: {
475+
tinymathAst: 0,
476+
},
477+
references: [],
478+
scale: 'ratio',
479+
},
480+
},
481+
});
482+
});
483+
443484
it('returns no change but error if the formula cannot be parsed', () => {
444485
const formulas = [
445486
'+',

x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ export const formulaOperation: OperationDefinition<
5555

5656
const visibleOperationsMap = filterByVisibleOperation(operationDefinitionMap);
5757
const { root, error } = tryToParse(column.params.formula, visibleOperationsMap);
58-
if (error || !root) {
59-
return [error!.message];
58+
if (error || root == null) {
59+
return error?.message ? [error.message] : [];
6060
}
6161

6262
const errors = runASTValidation(root, layer, indexPattern, visibleOperationsMap);

x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function parseAndExtract(
3535
label?: string
3636
) {
3737
const { root, error } = tryToParse(text, operationDefinitionMap);
38-
if (error || !root) {
38+
if (error || root == null) {
3939
return { extracted: [], isValid: false };
4040
}
4141
// before extracting the data run the validation task and throw if invalid

x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,6 @@ function getExpressionForLayer(
135135
}
136136
});
137137

138-
if (esAggEntries.length === 0) {
139-
// Return early if there are no aggs, for example if the user has an empty formula
140-
return null;
141-
}
142138
const idMap = esAggEntries.reduce((currentIdMap, [colId, column], index) => {
143139
const esAggsId = `col-${index}-${index}`;
144140
return {
@@ -234,6 +230,26 @@ function getExpressionForLayer(
234230
}
235231
);
236232

233+
if (esAggEntries.length === 0) {
234+
return {
235+
type: 'expression',
236+
chain: [
237+
{
238+
type: 'function',
239+
function: 'createTable',
240+
arguments: {
241+
ids: [],
242+
names: [],
243+
rowCount: [1],
244+
},
245+
},
246+
...expressions,
247+
...formatterOverrides,
248+
...timeScaleFunctions,
249+
],
250+
};
251+
}
252+
237253
const allDateHistogramFields = Object.values(columns)
238254
.map((column) =>
239255
column.operationType === dateHistogramOperation.type ? column.sourceField : null

0 commit comments

Comments
 (0)