From 3232fc80d11ed7311f493e20f01f01f0967c62e3 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Fri, 24 May 2019 14:07:26 +0200 Subject: [PATCH] Expressions service (#36885) --- .../kbn-interpreter/src/common/index.d.ts | 2 + .../kbn-interpreter/src/common/lib/ast.d.ts | 22 ++ .../expressions/expression_renderer.tsx | 59 +++++ .../public/expressions/expression_runner.ts | 72 ++++++ .../expressions/expressions_service.test.tsx | 217 ++++++++++++++++++ .../public/expressions/expressions_service.ts | 126 ++++++++++ .../data/public/expressions/index.ts | 22 ++ src/legacy/core_plugins/data/public/index.ts | 24 +- .../loader/pipeline_helpers/run_pipeline.ts | 6 +- 9 files changed, 546 insertions(+), 4 deletions(-) create mode 100644 packages/kbn-interpreter/src/common/lib/ast.d.ts create mode 100644 src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx create mode 100644 src/legacy/core_plugins/data/public/expressions/expression_runner.ts create mode 100644 src/legacy/core_plugins/data/public/expressions/expressions_service.test.tsx create mode 100644 src/legacy/core_plugins/data/public/expressions/expressions_service.ts create mode 100644 src/legacy/core_plugins/data/public/expressions/index.ts diff --git a/packages/kbn-interpreter/src/common/index.d.ts b/packages/kbn-interpreter/src/common/index.d.ts index b41f9f318a13b..a8917b7a65df1 100644 --- a/packages/kbn-interpreter/src/common/index.d.ts +++ b/packages/kbn-interpreter/src/common/index.d.ts @@ -18,3 +18,5 @@ */ export { Registry } from './lib/registry'; + +export { fromExpression, Ast } from './lib/ast'; diff --git a/packages/kbn-interpreter/src/common/lib/ast.d.ts b/packages/kbn-interpreter/src/common/lib/ast.d.ts new file mode 100644 index 0000000000000..2b0328bda9392 --- /dev/null +++ b/packages/kbn-interpreter/src/common/lib/ast.d.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type Ast = unknown; + +export declare function fromExpression(expression: string): Ast; diff --git a/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx b/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx new file mode 100644 index 0000000000000..80b5bacfc5adb --- /dev/null +++ b/src/legacy/core_plugins/data/public/expressions/expression_renderer.tsx @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useRef, useEffect } from 'react'; +import React from 'react'; +import { Ast } from '@kbn/interpreter/common'; + +import { ExpressionRunnerOptions, ExpressionRunner } from './expression_runner'; + +// Accept all options of the runner as props except for the +// dom element which is provided by the component itself +export type ExpressionRendererProps = Pick< + ExpressionRunnerOptions, + Exclude +> & { + expression: string | Ast; +}; + +export type ExpressionRenderer = React.FC; + +export const createRenderer = (run: ExpressionRunner): ExpressionRenderer => ({ + expression, + ...options +}: ExpressionRendererProps) => { + const mountpoint: React.MutableRefObject = useRef(null); + + useEffect( + () => { + if (mountpoint.current) { + run(expression, { ...options, element: mountpoint.current }); + } + }, + [expression, mountpoint.current] + ); + + return ( +
{ + mountpoint.current = el; + }} + /> + ); +}; diff --git a/src/legacy/core_plugins/data/public/expressions/expression_runner.ts b/src/legacy/core_plugins/data/public/expressions/expression_runner.ts new file mode 100644 index 0000000000000..26951ea605bf2 --- /dev/null +++ b/src/legacy/core_plugins/data/public/expressions/expression_runner.ts @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Ast, fromExpression } from '@kbn/interpreter/common'; + +import { RequestAdapter, DataAdapter } from 'ui/inspector/adapters'; +import { RenderFunctionsRegistry, Interpreter, Result } from './expressions_service'; + +export interface ExpressionRunnerOptions { + // TODO use the real types here once they are ready + context?: object; + getInitialContext?: () => object; + element?: Element; +} + +export type ExpressionRunner = ( + expression: string | Ast, + options: ExpressionRunnerOptions +) => Promise; + +export const createRunFn = ( + renderersRegistry: RenderFunctionsRegistry, + interpreterPromise: Promise +): ExpressionRunner => async (expressionOrAst, { element, context, getInitialContext }) => { + // TODO: make interpreter initialization synchronous to avoid this + const interpreter = await interpreterPromise; + const ast = + typeof expressionOrAst === 'string' ? fromExpression(expressionOrAst) : expressionOrAst; + + const response = await interpreter.interpretAst(ast, context || { type: 'null' }, { + getInitialContext: getInitialContext || (() => ({})), + inspectorAdapters: { + // TODO connect real adapters + requests: new RequestAdapter(), + data: new DataAdapter(), + }, + }); + + if (element) { + if (response.type === 'render' && response.as) { + renderersRegistry.get(response.as).render(element, response.value, { + onDestroy: fn => { + // TODO implement + }, + done: () => { + // TODO implement + }, + }); + } else { + // eslint-disable-next-line no-console + console.log('Unexpected result of expression', response); + } + } + + return response; +}; diff --git a/src/legacy/core_plugins/data/public/expressions/expressions_service.test.tsx b/src/legacy/core_plugins/data/public/expressions/expressions_service.test.tsx new file mode 100644 index 0000000000000..9a464da2731c8 --- /dev/null +++ b/src/legacy/core_plugins/data/public/expressions/expressions_service.test.tsx @@ -0,0 +1,217 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +import { + ExpressionsService, + RenderFunctionsRegistry, + RenderFunction, + Interpreter, + ExpressionsServiceDependencies, + Result, + ExpressionsSetup, +} from './expressions_service'; +import { mount } from 'enzyme'; +import React from 'react'; + +const waitForInterpreterRun = async () => { + // Wait for two ticks with empty callback queues + // This makes sure the runFn promise and actual interpretAst + // promise have been resolved and processed + await new Promise(resolve => setTimeout(resolve)); + await new Promise(resolve => setTimeout(resolve)); +}; + +describe('expressions_service', () => { + let interpreterMock: jest.Mocked; + let renderFunctionMock: jest.Mocked; + let setupPluginsMock: ExpressionsServiceDependencies; + const expressionResult: Result = { type: 'render', as: 'abc', value: {} }; + + let api: ExpressionsSetup; + let testExpression: string; + let testAst: Ast; + + beforeEach(() => { + interpreterMock = { interpretAst: jest.fn(_ => Promise.resolve(expressionResult)) }; + renderFunctionMock = ({ + render: jest.fn(), + } as unknown) as jest.Mocked; + setupPluginsMock = { + interpreter: { + getInterpreter: () => Promise.resolve({ interpreter: interpreterMock }), + renderersRegistry: ({ + get: () => renderFunctionMock, + } as unknown) as RenderFunctionsRegistry, + }, + }; + api = new ExpressionsService().setup(setupPluginsMock); + testExpression = 'test | expression'; + testAst = fromExpression(testExpression); + }); + + describe('expression_runner', () => { + it('should return run function', () => { + expect(typeof api.run).toBe('function'); + }); + + it('should call the interpreter with parsed expression', async () => { + await api.run(testExpression, { element: document.createElement('div') }); + expect(interpreterMock.interpretAst).toHaveBeenCalledWith( + testAst, + expect.anything(), + expect.anything() + ); + }); + + it('should call the interpreter with given context and getInitialContext functions', async () => { + const getInitialContext = () => ({}); + const context = {}; + + await api.run(testExpression, { getInitialContext, context }); + const interpretCall = interpreterMock.interpretAst.mock.calls[0]; + + expect(interpretCall[1]).toBe(context); + expect(interpretCall[2].getInitialContext).toBe(getInitialContext); + }); + + it('should call the interpreter with passed in ast', async () => { + await api.run(testAst, { element: document.createElement('div') }); + expect(interpreterMock.interpretAst).toHaveBeenCalledWith( + testAst, + expect.anything(), + expect.anything() + ); + }); + + it('should call the render function with the result and element', async () => { + const element = document.createElement('div'); + + await api.run(testAst, { element }); + expect(renderFunctionMock.render).toHaveBeenCalledWith( + element, + expressionResult.value, + expect.anything() + ); + expect(interpreterMock.interpretAst).toHaveBeenCalledWith( + testAst, + expect.anything(), + expect.anything() + ); + }); + }); + + describe('expression_renderer', () => { + it('should call interpreter and render function when called through react component', async () => { + const ExpressionRenderer = api.ExpressionRenderer; + + mount(); + + await waitForInterpreterRun(); + + expect(renderFunctionMock.render).toHaveBeenCalledWith( + expect.any(Element), + expressionResult.value, + expect.anything() + ); + expect(interpreterMock.interpretAst).toHaveBeenCalledWith( + testAst, + expect.anything(), + expect.anything() + ); + }); + + it('should call the interpreter with given context and getInitialContext functions', async () => { + const getInitialContext = () => ({}); + const context = {}; + + const ExpressionRenderer = api.ExpressionRenderer; + + mount( + + ); + + await waitForInterpreterRun(); + + const interpretCall = interpreterMock.interpretAst.mock.calls[0]; + + expect(interpretCall[1]).toBe(context); + expect(interpretCall[2].getInitialContext).toBe(getInitialContext); + }); + + it('should call interpreter and render function again if expression changes', async () => { + const ExpressionRenderer = api.ExpressionRenderer; + + const instance = mount(); + + await waitForInterpreterRun(); + + expect(renderFunctionMock.render).toHaveBeenCalledWith( + expect.any(Element), + expressionResult.value, + expect.anything() + ); + expect(interpreterMock.interpretAst).toHaveBeenCalledWith( + testAst, + expect.anything(), + expect.anything() + ); + + instance.setProps({ expression: 'supertest | expression ' }); + + await waitForInterpreterRun(); + + expect(renderFunctionMock.render).toHaveBeenCalledTimes(2); + expect(interpreterMock.interpretAst).toHaveBeenCalledTimes(2); + }); + + it('should not call interpreter and render function again if expression does not change', async () => { + const ast = fromExpression(testExpression); + + const ExpressionRenderer = api.ExpressionRenderer; + + const instance = mount(); + + await waitForInterpreterRun(); + + expect(renderFunctionMock.render).toHaveBeenCalledWith( + expect.any(Element), + expressionResult.value, + expect.anything() + ); + expect(interpreterMock.interpretAst).toHaveBeenCalledWith( + ast, + expect.anything(), + expect.anything() + ); + + instance.update(); + + await waitForInterpreterRun(); + + expect(renderFunctionMock.render).toHaveBeenCalledTimes(1); + expect(interpreterMock.interpretAst).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/legacy/core_plugins/data/public/expressions/expressions_service.ts b/src/legacy/core_plugins/data/public/expressions/expressions_service.ts new file mode 100644 index 0000000000000..308fd44d6bc08 --- /dev/null +++ b/src/legacy/core_plugins/data/public/expressions/expressions_service.ts @@ -0,0 +1,126 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Ast } from '@kbn/interpreter/common'; + +// TODO: +// this type import and the types below them should be switched to the types of +// the interpreter plugin itself once they are ready +import { Registry } from '@kbn/interpreter/common'; +import { Adapters } from 'ui/inspector'; +import { Query, Filters, TimeRange } from 'ui/embeddable'; +import { createRenderer } from './expression_renderer'; +import { createRunFn } from './expression_runner'; + +export interface InitialContextObject { + timeRange?: TimeRange; + filters?: Filters; + query?: Query; +} + +export type getInitialContextFunction = () => InitialContextObject; + +export interface Handlers { + getInitialContext: getInitialContextFunction; + inspectorAdapters?: Adapters; +} + +type Context = object; +export interface Result { + type: string; + as?: string; + value?: unknown; +} + +interface RenderHandlers { + done: () => void; + onDestroy: (fn: () => void) => void; +} + +export interface RenderFunction { + name: string; + displayName: string; + help: string; + validate: () => void; + reuseDomNode: boolean; + render: (domNode: Element, data: unknown, handlers: RenderHandlers) => void; +} + +export type RenderFunctionsRegistry = Registry; + +export interface Interpreter { + interpretAst(ast: Ast, context: Context, handlers: Handlers): Promise; +} + +type InterpreterGetter = () => Promise<{ interpreter: Interpreter }>; + +export interface ExpressionsServiceDependencies { + interpreter: { + renderersRegistry: RenderFunctionsRegistry; + getInterpreter: InterpreterGetter; + }; +} + +/** + * Expressions Service + * @internal + */ +export class ExpressionsService { + public setup({ + interpreter: { renderersRegistry, getInterpreter }, + }: ExpressionsServiceDependencies) { + const run = createRunFn( + renderersRegistry, + getInterpreter().then(({ interpreter }) => interpreter) + ); + + return { + /** + * **experimential** This API is experimential and might be removed in the future + * without notice + * + * Executes the given expression string or ast and renders the result into the + * given DOM element. + * + * + * @param expressionOrAst + * @param element + */ + run, + /** + * **experimential** This API is experimential and might be removed in the future + * without notice + * + * Component which executes and renders the given expression in a div element. + * The expression is re-executed on updating the props. + * + * This is a React bridge of the `run` method + * @param props + */ + ExpressionRenderer: createRenderer(run), + }; + } + + public stop() { + // nothing to do here yet + } +} + +/** @public */ +export type ExpressionsSetup = ReturnType; diff --git a/src/legacy/core_plugins/data/public/expressions/index.ts b/src/legacy/core_plugins/data/public/expressions/index.ts new file mode 100644 index 0000000000000..fceefce44f81f --- /dev/null +++ b/src/legacy/core_plugins/data/public/expressions/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ExpressionsService, ExpressionsSetup } from './expressions_service'; +export { ExpressionRenderer, ExpressionRendererProps } from './expression_renderer'; +export { ExpressionRunner } from './expression_runner'; diff --git a/src/legacy/core_plugins/data/public/index.ts b/src/legacy/core_plugins/data/public/index.ts index 8e2aa52efb431..fe695597c4913 100644 --- a/src/legacy/core_plugins/data/public/index.ts +++ b/src/legacy/core_plugins/data/public/index.ts @@ -17,6 +17,15 @@ * under the License. */ +// TODO these are imports from the old plugin world. +// Once the new platform is ready, they can get removed +// and handled by the platform itself in the setup method +// of the ExpressionExectorService +// @ts-ignore +import { getInterpreter } from 'plugins/interpreter/interpreter'; +// @ts-ignore +import { renderersRegistry } from 'plugins/interpreter/registries'; +import { ExpressionsService, ExpressionsSetup } from './expressions'; import { SearchService, SearchSetup } from './search'; import { QueryService, QuerySetup } from './query'; import { IndexPatternsService, IndexPatternsSetup } from './index_patterns'; @@ -25,18 +34,26 @@ class DataPlugin { private readonly indexPatterns: IndexPatternsService; private readonly search: SearchService; private readonly query: QueryService; + private readonly expressions: ExpressionsService; constructor() { this.indexPatterns = new IndexPatternsService(); this.query = new QueryService(); this.search = new SearchService(); + this.expressions = new ExpressionsService(); } - public setup() { + public setup(): DataSetup { return { indexPatterns: this.indexPatterns.setup(), search: this.search.setup(), query: this.query.setup(), + expressions: this.expressions.setup({ + interpreter: { + getInterpreter, + renderersRegistry, + }, + }), }; } @@ -44,6 +61,7 @@ class DataPlugin { this.indexPatterns.stop(); this.search.stop(); this.query.stop(); + this.expressions.stop(); } } @@ -57,9 +75,13 @@ export const data = new DataPlugin().setup(); /** @public */ export interface DataSetup { indexPatterns: IndexPatternsSetup; + expressions: ExpressionsSetup; search: SearchSetup; query: QuerySetup; } +/** @public types */ +export { ExpressionRenderer, ExpressionRendererProps, ExpressionRunner } from './expressions'; + /** @public types */ export { IndexPattern, StaticIndexPattern, StaticIndexPatternField, Field } from './index_patterns'; diff --git a/src/legacy/ui/public/visualize/loader/pipeline_helpers/run_pipeline.ts b/src/legacy/ui/public/visualize/loader/pipeline_helpers/run_pipeline.ts index 459d9dd6a6932..6e08bde332147 100644 --- a/src/legacy/ui/public/visualize/loader/pipeline_helpers/run_pipeline.ts +++ b/src/legacy/ui/public/visualize/loader/pipeline_helpers/run_pipeline.ts @@ -25,15 +25,15 @@ import { getInterpreter } from 'plugins/interpreter/interpreter'; import { Adapters } from 'ui/inspector'; import { Filters, Query, TimeRange } from 'ui/visualize'; -interface InitialContextObject { +export interface InitialContextObject { timeRange?: TimeRange; filters?: Filters; query?: Query; } -type getInitialContextFunction = () => InitialContextObject; +export type getInitialContextFunction = () => InitialContextObject; -interface RunPipelineHandlers { +export interface RunPipelineHandlers { getInitialContext: getInitialContextFunction; inspectorAdapters?: Adapters; }