[Lens] give data plugin control of filter extraction, injection, and migrations (#120305)

This commit is contained in:
Andrew Tate 2022-01-13 11:12:16 -06:00 committed by GitHub
parent 1aadcd34c6
commit efc07eed86
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1092 additions and 378 deletions

View file

@ -20,6 +20,7 @@ import { UsageCollectionSetup } from '../../usage_collection/server';
import { AutocompleteService } from './autocomplete';
import { FieldFormatsSetup, FieldFormatsStart } from '../../field_formats/server';
import { getUiSettings } from './ui_settings';
import { QuerySetup } from './query';
interface DataEnhancements {
search: SearchEnhancements;
@ -27,6 +28,7 @@ interface DataEnhancements {
export interface DataPluginSetup {
search: ISearchSetup;
query: QuerySetup;
/**
* @deprecated - use "fieldFormats" plugin directly instead
*/
@ -88,7 +90,7 @@ export class DataServerPlugin
{ bfetch, expressions, usageCollection, fieldFormats }: DataPluginSetupDependencies
) {
this.scriptsService.setup(core);
this.queryService.setup(core);
const querySetup = this.queryService.setup(core);
this.autocompleteService.setup(core);
this.kqlTelemetryService.setup(core, { usageCollection });
@ -105,6 +107,7 @@ export class DataServerPlugin
searchSetup.__enhance(enhancements.search);
},
search: searchSetup,
query: querySetup,
fieldFormats,
};
}

View file

@ -7,3 +7,4 @@
*/
export { QueryService } from './query_service';
export type { QuerySetup } from './query_service';

View file

@ -36,3 +36,6 @@ export class QueryService implements Plugin<void> {
public start() {}
}
/** @public */
export type QuerySetup = ReturnType<QueryService['setup']>;

View file

@ -92,8 +92,8 @@ export interface PersistableState<P extends SerializableRecord = SerializableRec
*/
export type MigrateFunctionsObject = { [semver: string]: MigrateFunction<any, any> };
export type MigrateFunction<
FromVersion extends SerializableRecord = SerializableRecord,
ToVersion extends SerializableRecord = SerializableRecord
FromVersion extends Serializable = SerializableRecord,
ToVersion extends Serializable = SerializableRecord
> = (state: FromVersion) => ToVersion;
/**

View file

@ -6,7 +6,7 @@
*/
import { SavedObject, SavedObjectsFindResponse } from 'kibana/server';
import { lensEmbeddableFactory } from '../../../lens/server/embeddable/lens_embeddable_factory';
import { makeLensEmbeddableFactory } from '../../../lens/server/embeddable/make_lens_embeddable_factory';
import { SECURITY_SOLUTION_OWNER } from '../../common/constants';
import {
AssociationType,
@ -879,7 +879,7 @@ describe('common utils', () => {
].join('\n\n');
const extractedReferences = extractLensReferencesFromCommentString(
lensEmbeddableFactory,
makeLensEmbeddableFactory({}),
commentString
);
@ -977,12 +977,16 @@ describe('common utils', () => {
)}},"editMode":false}}`,
].join('\n\n');
const updatedReferences = getOrUpdateLensReferences(lensEmbeddableFactory, newCommentString, {
references: currentCommentReferences,
attributes: {
comment: currentCommentString,
},
} as SavedObject<CommentRequestUserType>);
const updatedReferences = getOrUpdateLensReferences(
makeLensEmbeddableFactory({}),
newCommentString,
{
references: currentCommentReferences,
attributes: {
comment: currentCommentString,
},
} as SavedObject<CommentRequestUserType>
);
const expectedReferences = [
...nonLensCurrentCommentReferences,

View file

@ -17,7 +17,7 @@ import {
} from '../../../common/utils/markdown_plugins/utils';
import { savedObjectsServiceMock } from '../../../../../../src/core/server/mocks';
import { lensEmbeddableFactory } from '../../../../lens/server/embeddable/lens_embeddable_factory';
import { makeLensEmbeddableFactory } from '../../../../lens/server/embeddable/make_lens_embeddable_factory';
import { LensDocShape715 } from '../../../../lens/server';
import {
SavedObjectReference,
@ -32,7 +32,7 @@ import { SerializableRecord } from '@kbn/utility-types';
describe('comments migrations', () => {
const migrations = createCommentsMigrations({
lensEmbeddableFactory,
lensEmbeddableFactory: makeLensEmbeddableFactory({}),
});
const contextMock = savedObjectsServiceMock.createMigrationContext();

View file

@ -126,11 +126,13 @@ describe('Lens App', () => {
defaultSavedObjectId = '1234';
defaultDoc = {
savedObjectId: defaultSavedObjectId,
visualizationType: 'testVis',
type: 'lens',
title: 'An extremely cool default document!',
expression: 'definitely a valid expression',
state: {
query: 'lucene',
filters: [{ query: { match_phrase: { src: 'test' } } }],
filters: [{ query: { match_phrase: { src: 'test' } }, meta: { index: 'index-pattern-0' } }],
},
references: [{ type: 'index-pattern', id: '1', name: 'index-pattern-0' }],
} as unknown as Document;
@ -685,7 +687,7 @@ describe('Lens App', () => {
savedObjectId: defaultSavedObjectId,
title: 'hello there2',
state: expect.objectContaining({
filters: [unpinned],
filters: services.data.query.filterManager.inject([unpinned], []),
}),
}),
true,
@ -1252,24 +1254,28 @@ describe('Lens App', () => {
});
it('should not confirm when changes are saved', async () => {
const { props } = await mountWith({
preloadedState: {
persistedDoc: {
...defaultDoc,
state: {
...defaultDoc.state,
datasourceStates: { testDatasource: {} },
visualization: {},
},
},
isSaveable: true,
...(defaultDoc.state as Partial<LensAppState>),
visualization: {
activeId: 'testVis',
state: {},
const preloadedState = {
persistedDoc: {
...defaultDoc,
state: {
...defaultDoc.state,
datasourceStates: { testDatasource: {} },
visualization: {},
},
},
});
isSaveable: true,
...(defaultDoc.state as Partial<LensAppState>),
visualization: {
activeId: 'testVis',
state: {},
},
};
const customProps = makeDefaultProps();
customProps.datasourceMap.testDatasource.isEqual = () => true; // if this returns false, the documents won't be accounted equal
const { props } = await mountWith({ preloadedState, props: customProps });
const lastCall = props.onAppLeave.mock.calls[props.onAppLeave.mock.calls.length - 1][0];
lastCall({ default: defaultLeave, confirm: confirmLeave });
expect(defaultLeave).toHaveBeenCalled();

View file

@ -7,8 +7,7 @@
import './app.scss';
import { isEqual } from 'lodash';
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBreadcrumb } from '@elastic/eui';
import {
@ -32,13 +31,10 @@ import {
DispatchSetState,
selectSavedObjectFormat,
} from '../state_management';
import {
SaveModalContainer,
getLastKnownDocWithoutPinnedFilters,
runSaveLensVisualization,
} from './save_modal_container';
import { SaveModalContainer, runSaveLensVisualization } from './save_modal_container';
import { LensInspector } from '../lens_inspector_service';
import { getEditPath } from '../../common';
import { isLensEqual } from './lens_document_equality';
export type SaveProps = Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
returnToOrigin: boolean;
@ -92,8 +88,17 @@ export function App({
isSaveable,
} = useLensSelector((state) => state.lens);
const selectorDependencies = useMemo(
() => ({
datasourceMap,
visualizationMap,
extractFilterReferences: data.query.filterManager.extract,
}),
[datasourceMap, visualizationMap, data.query.filterManager.extract]
);
const currentDoc = useLensSelector((state) =>
selectSavedObjectFormat(state, datasourceMap, visualizationMap)
selectSavedObjectFormat(state, selectorDependencies)
);
// Used to show a popover that guides the user towards changing the date range when no data is available.
@ -146,12 +151,9 @@ export function App({
useEffect(() => {
onAppLeave((actions) => {
// Confirm when the user has made any changes to an existing doc
// or when the user has configured something without saving
if (
application.capabilities.visualize.save &&
!isEqual(persistedDoc?.state, getLastKnownDocWithoutPinnedFilters(lastKnownDoc)?.state) &&
!isLensEqual(persistedDoc, lastKnownDoc, data.query.filterManager.inject, datasourceMap) &&
(isSaveable || persistedDoc)
) {
return actions.confirm(
@ -166,7 +168,15 @@ export function App({
return actions.default();
}
});
}, [onAppLeave, lastKnownDoc, isSaveable, persistedDoc, application.capabilities.visualize.save]);
}, [
onAppLeave,
lastKnownDoc,
isSaveable,
persistedDoc,
application.capabilities.visualize.save,
data.query.filterManager.inject,
datasourceMap,
]);
const getLegacyUrlConflictCallout = useCallback(() => {
// This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario

View file

@ -0,0 +1,248 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Filter, FilterStateStore } from '@kbn/es-query';
import { isLensEqual } from './lens_document_equality';
import { Document } from '../persistence/saved_object_store';
import { Datasource, DatasourceMap } from '../types';
const defaultDoc: Document = {
title: 'some-title',
visualizationType: 'lnsXY',
state: {
query: {
query: '',
language: 'kuery',
},
visualization: {
some: 'props',
},
datasourceStates: {
indexpattern: {},
},
filters: [
{
meta: {
index: 'reference-1',
},
},
],
},
references: [
{
name: 'reference-1',
id: 'id-1',
type: 'index-pattern',
},
],
};
describe('lens document equality', () => {
const mockInjectFilterReferences = jest.fn((filters: Filter[]) =>
filters.map((filter) => ({
...filter,
meta: {
...filter.meta,
index: 'injected!',
},
}))
);
let mockDatasourceMap: DatasourceMap;
beforeEach(() => {
mockDatasourceMap = {
indexpattern: { isEqual: jest.fn(() => true) },
} as unknown as DatasourceMap;
});
it('returns true when documents are equal', () => {
expect(
isLensEqual(defaultDoc, defaultDoc, mockInjectFilterReferences, mockDatasourceMap)
).toBeTruthy();
});
it('handles undefined documents', () => {
expect(isLensEqual(undefined, undefined, mockInjectFilterReferences, {})).toBeTruthy();
expect(isLensEqual(undefined, {} as Document, mockInjectFilterReferences, {})).toBeFalsy();
expect(isLensEqual({} as Document, undefined, mockInjectFilterReferences, {})).toBeFalsy();
});
it('should compare visualization type', () => {
expect(
isLensEqual(
defaultDoc,
{ ...defaultDoc, visualizationType: 'other-type' },
mockInjectFilterReferences,
mockDatasourceMap
)
).toBeFalsy();
});
it('should compare the query', () => {
expect(
isLensEqual(
defaultDoc,
{
...defaultDoc,
state: {
...defaultDoc.state,
query: {
query: 'foobar',
language: 'kuery',
},
},
},
mockInjectFilterReferences,
mockDatasourceMap
)
).toBeFalsy();
});
it('should compare the visualization state', () => {
expect(
isLensEqual(
defaultDoc,
{
...defaultDoc,
state: {
...defaultDoc.state,
visualization: {
some: 'other-props',
},
},
},
mockInjectFilterReferences,
mockDatasourceMap
)
).toBeFalsy();
});
describe('comparing the datasources', () => {
it('checks available datasources', () => {
// add an extra datasource in one doc
expect(
isLensEqual(
defaultDoc,
{
...defaultDoc,
state: {
...defaultDoc.state,
datasourceStates: {
...defaultDoc.state.datasourceStates,
foodatasource: {},
},
},
},
mockInjectFilterReferences,
{ ...mockDatasourceMap, foodatasource: { isEqual: () => true } as unknown as Datasource }
)
).toBeFalsy();
// ordering of the datasource states shouldn't matter
expect(
isLensEqual(
{
...defaultDoc,
state: {
...defaultDoc.state,
datasourceStates: {
foodatasource: {}, // first
...defaultDoc.state.datasourceStates,
},
},
},
{
...defaultDoc,
state: {
...defaultDoc.state,
datasourceStates: {
...defaultDoc.state.datasourceStates,
foodatasource: {}, // last
},
},
},
mockInjectFilterReferences,
{ ...mockDatasourceMap, foodatasource: { isEqual: () => true } as unknown as Datasource }
)
).toBeTruthy();
});
it('delegates internal datasource comparison', () => {
// datasource's isEqual returns false
(mockDatasourceMap.indexpattern.isEqual as jest.Mock).mockReturnValue(false);
expect(
isLensEqual(defaultDoc, defaultDoc, mockInjectFilterReferences, mockDatasourceMap)
).toBeFalsy();
});
});
it('should ignore pinned filters', () => {
// ignores pinned filters
const pinnedFilter: Filter = {
$state: {
store: FilterStateStore.GLOBAL_STATE,
},
meta: {},
};
const filtersWithPinned = [...defaultDoc.state.filters, pinnedFilter];
expect(
isLensEqual(
defaultDoc,
{ ...defaultDoc, state: { ...defaultDoc.state, filters: filtersWithPinned } },
mockInjectFilterReferences,
mockDatasourceMap
)
).toBeTruthy();
});
it('should inject filter references', () => {
// injects filter references for comparison
expect(
isLensEqual(
defaultDoc,
{
...defaultDoc,
state: {
...defaultDoc.state,
filters: [
{
meta: {
index: 'some-other-reference-name',
},
},
],
},
},
mockInjectFilterReferences,
mockDatasourceMap
)
).toBeTruthy();
});
it('should consider undefined props equivalent to non-existant props', () => {
expect(
isLensEqual(
defaultDoc,
{
...defaultDoc,
state: {
...defaultDoc.state,
visualization: {
...(defaultDoc.state.visualization as object),
foo: undefined,
},
},
},
mockInjectFilterReferences,
mockDatasourceMap
)
).toBeTruthy();
});
});

View file

@ -0,0 +1,77 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEqual, intersection, union } from 'lodash';
import { FilterManager } from 'src/plugins/data/public';
import { Document } from '../persistence/saved_object_store';
import { DatasourceMap } from '../types';
import { injectDocFilterReferences, removePinnedFilters } from './save_modal_container';
const removeNonSerializable = (obj: Parameters<JSON['stringify']>[0]) =>
JSON.parse(JSON.stringify(obj));
export const isLensEqual = (
doc1In: Document | undefined,
doc2In: Document | undefined,
injectFilterReferences: FilterManager['inject'],
datasourceMap: DatasourceMap
) => {
if (doc1In === undefined || doc2In === undefined) {
return doc1In === doc2In;
}
// we do this so that undefined props are the same as non-existant props
const doc1 = removeNonSerializable(doc1In);
const doc2 = removeNonSerializable(doc2In);
if (doc1?.visualizationType !== doc2?.visualizationType) {
return false;
}
if (!isEqual(doc1.state.query, doc2.state.query)) {
return false;
}
if (!isEqual(doc1.state.visualization, doc2.state.visualization)) {
return false;
}
// data source equality
const availableDatasourceTypes1 = Object.keys(doc1.state.datasourceStates);
const availableDatasourceTypes2 = Object.keys(doc2.state.datasourceStates);
let datasourcesEqual =
intersection(availableDatasourceTypes1, availableDatasourceTypes2).length ===
union(availableDatasourceTypes1, availableDatasourceTypes2).length;
if (datasourcesEqual) {
// equal so far, so actually check
datasourcesEqual = availableDatasourceTypes1
.map((type) =>
datasourceMap[type].isEqual(
doc1.state.datasourceStates[type],
doc1.references,
doc2.state.datasourceStates[type],
doc2.references
)
)
.every((res) => res);
}
if (!datasourcesEqual) {
return false;
}
const [filtersInjected1, filtersInjected2] = [doc1, doc2].map((doc) =>
removePinnedFilters(injectDocFilterReferences(injectFilterReferences, doc))
);
if (!isEqual(filtersInjected1?.state.filters, filtersInjected2?.state.filters)) {
return false;
}
return true;
};

View file

@ -8,15 +8,14 @@
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
import { partition } from 'lodash';
import type { SavedObjectReference } from 'kibana/public';
import { SaveModal } from './save_modal';
import type { LensAppProps, LensAppServices } from './types';
import type { SaveProps } from './app';
import { Document, injectFilterReferences, checkForDuplicateTitle } from '../persistence';
import { Document, checkForDuplicateTitle } from '../persistence';
import type { LensByReferenceInput, LensEmbeddableInput } from '../embeddable';
import { esFilters } from '../../../../../src/plugins/data/public';
import { esFilters, FilterManager } from '../../../../../src/plugins/data/public';
import { APP_ID, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../common';
import { trackUiEvent } from '../lens_ui_telemetry';
import type { LensAppState } from '../state_management';
@ -169,10 +168,11 @@ const redirectToDashboard = ({
const getDocToSave = (
lastKnownDoc: Document,
saveProps: SaveProps,
references: SavedObjectReference[]
references: SavedObjectReference[],
injectFilterReferences: FilterManager['inject']
) => {
const docToSave = {
...getLastKnownDocWithoutPinnedFilters(lastKnownDoc)!,
...injectDocFilterReferences(injectFilterReferences, removePinnedFilters(lastKnownDoc))!,
references,
};
@ -200,6 +200,7 @@ export const runSaveLensVisualization = async (
): Promise<Partial<LensAppState> | undefined> => {
const {
chrome,
data,
initialInput,
originatingApp,
lastKnownDoc,
@ -240,7 +241,12 @@ export const runSaveLensVisualization = async (
);
}
const docToSave = getDocToSave(lastKnownDoc, saveProps, references);
const docToSave = getDocToSave(
lastKnownDoc,
saveProps,
references,
data.query.filterManager.inject
);
// Required to serialize filters in by value mode until
// https://github.com/elastic/kibana/issues/77588 is fixed
@ -351,21 +357,29 @@ export const runSaveLensVisualization = async (
}
};
export function getLastKnownDocWithoutPinnedFilters(doc?: Document) {
export function injectDocFilterReferences(
injectFilterReferences: FilterManager['inject'],
doc?: Document
) {
if (!doc) return undefined;
const [pinnedFilters, appFilters] = partition(
injectFilterReferences(doc.state?.filters || [], doc.references),
esFilters.isFilterPinned
);
return pinnedFilters?.length
? {
...doc,
state: {
...doc.state,
filters: appFilters,
},
}
: doc;
return {
...doc,
state: {
...doc.state,
filters: injectFilterReferences(doc.state?.filters || [], doc.references),
},
};
}
export function removePinnedFilters(doc?: Document) {
if (!doc) return undefined;
return {
...doc,
state: {
...doc.state,
filters: (doc.state?.filters || []).filter((filter) => !esFilters.isFilterPinned(filter)),
},
};
}
// eslint-disable-next-line import/no-default-export

View file

@ -17,7 +17,7 @@ import {
import { ReactExpressionRendererProps } from 'src/plugins/expressions/public';
import { spacesPluginMock } from '../../../spaces/public/mocks';
import { Filter } from '@kbn/es-query';
import { Query, TimeRange, IndexPatternsContract } from 'src/plugins/data/public';
import { Query, TimeRange, IndexPatternsContract, FilterManager } from 'src/plugins/data/public';
import { Document } from '../persistence';
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public/embeddable';
@ -72,6 +72,16 @@ const options = {
checkForDuplicateTitle: defaultCheckForDuplicateTitle,
};
const mockInjectFilterReferences: FilterManager['inject'] = (filters, references) => {
return filters.map((filter) => ({
...filter,
meta: {
...filter.meta,
index: 'injected!',
},
}));
};
const attributeServiceMockFromSavedVis = (document: Document): LensAttributeService => {
const core = coreMock.createStart();
const service = new AttributeService<
@ -139,6 +149,7 @@ describe('embeddable', () => {
getTrigger,
theme: themeServiceMock.createStartContract(),
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
documentToExpression: () =>
Promise.resolve({
ast: {
@ -180,6 +191,7 @@ describe('embeddable', () => {
capabilities: { canSaveDashboards: true, canSaveVisualizations: true },
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -226,6 +238,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -283,6 +296,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -329,6 +343,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -370,6 +385,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -414,6 +430,7 @@ describe('embeddable', () => {
capabilities: { canSaveDashboards: true, canSaveVisualizations: true },
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -465,6 +482,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -514,6 +532,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -570,6 +589,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -592,7 +612,7 @@ describe('embeddable', () => {
expect.objectContaining({
timeRange,
query: [query, savedVis.state.query],
filters,
filters: mockInjectFilterReferences(filters, []),
})
);
@ -627,6 +647,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -663,9 +684,7 @@ describe('embeddable', () => {
state: {
...savedVis.state,
query: { language: 'kquery', query: 'saved filter' },
filters: [
{ meta: { alias: 'test', negate: false, disabled: false, indexRefName: 'filter-0' } },
],
filters: [{ meta: { alias: 'test', negate: false, disabled: false, index: 'filter-0' } }],
},
references: [{ type: 'index-pattern', name: 'filter-0', id: 'my-index-pattern-id' }],
};
@ -687,6 +706,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -708,11 +728,14 @@ describe('embeddable', () => {
expect(expressionRenderer.mock.calls[0][0].searchContext).toEqual({
timeRange,
query: [query, { language: 'kquery', query: 'saved filter' }],
filters: [
filters[0],
// actual index pattern id gets injected
{ meta: { alias: 'test', negate: false, disabled: false, index: 'my-index-pattern-id' } },
],
// actual index pattern id gets injected
filters: mockInjectFilterReferences(
[
filters[0],
{ meta: { alias: 'test', negate: false, disabled: false, index: 'injected!' } },
],
[]
),
});
});
@ -731,6 +754,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -775,6 +799,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -819,6 +844,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -878,6 +904,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -953,6 +980,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -1003,6 +1031,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -1053,6 +1082,7 @@ describe('embeddable', () => {
},
getTrigger,
visualizationMap: {},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
theme: themeServiceMock.createStartContract(),
documentToExpression: () =>
Promise.resolve({
@ -1124,6 +1154,7 @@ describe('embeddable', () => {
},
getTrigger,
theme: themeServiceMock.createStartContract(),
injectFilterReferences: jest.fn(mockInjectFilterReferences),
visualizationMap: {
[visDocument.visualizationType as string]: {
onEditAction: onEditActionMock,

View file

@ -16,6 +16,7 @@ import type {
TimefilterContract,
TimeRange,
IndexPattern,
FilterManager,
} from 'src/plugins/data/public';
import type { PaletteOutput } from 'src/plugins/charts/public';
import type { Start as InspectorStart } from 'src/plugins/inspector/public';
@ -42,7 +43,7 @@ import {
SavedObjectEmbeddableInput,
ReferenceOrValueEmbeddable,
} from '../../../../../src/plugins/embeddable/public';
import { Document, injectFilterReferences } from '../persistence';
import { Document } from '../persistence';
import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import {
@ -107,6 +108,7 @@ export interface LensEmbeddableDeps {
documentToExpression: (
doc: Document
) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>;
injectFilterReferences: FilterManager['inject'];
visualizationMap: VisualizationMap;
indexPatternService: IndexPatternsContract;
expressionRenderer: ReactExpressionRendererType;
@ -477,7 +479,7 @@ export class Embeddable
output.filters = [...this.savedVis.state.filters];
}
output.filters = injectFilterReferences(output.filters, this.savedVis.references);
output.filters = this.deps.injectFilterReferences(output.filters, this.savedVis.references);
return output;
}

View file

@ -10,7 +10,11 @@ import { i18n } from '@kbn/i18n';
import { RecursiveReadonly } from '@kbn/utility-types';
import { Ast } from '@kbn/interpreter';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/public';
import { IndexPatternsContract, TimefilterContract } from '../../../../../src/plugins/data/public';
import {
FilterManager,
IndexPatternsContract,
TimefilterContract,
} from '../../../../../src/plugins/data/public';
import { ReactExpressionRendererType } from '../../../../../src/plugins/expressions/public';
import {
EmbeddableFactoryDefinition,
@ -40,6 +44,7 @@ export interface LensEmbeddableStartServices {
documentToExpression: (
doc: Document
) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>;
injectFilterReferences: FilterManager['inject'];
visualizationMap: VisualizationMap;
spaces?: SpacesPluginStart;
theme: ThemeServiceStart;
@ -88,6 +93,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
timefilter,
expressionRenderer,
documentToExpression,
injectFilterReferences,
visualizationMap,
uiActions,
coreHttp,
@ -113,6 +119,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition {
getTrigger: uiActions?.getTrigger,
getTriggerCompatibleActions: uiActions?.getTriggerCompatibleActions,
documentToExpression,
injectFilterReferences,
visualizationMap,
capabilities: {
canSaveDashboards: Boolean(capabilities.dashboard?.showWriteControls),

View file

@ -28,3 +28,5 @@ export function loadInitialState() {
const originalLoader = jest.requireActual('../loader');
export const extractReferences = originalLoader.extractReferences;
export const injectReferences = originalLoader.injectReferences;

View file

@ -29,6 +29,8 @@ import { indexPatternFieldEditorPluginMock } from 'src/plugins/data_view_field_e
import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/public/mocks';
import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks';
import { TinymathAST } from 'packages/kbn-tinymath';
import { SavedObjectReference } from 'kibana/server';
import { cloneDeep } from 'lodash';
jest.mock('./loader');
jest.mock('../id_generator');
@ -1737,4 +1739,77 @@ describe('IndexPattern Data Source', () => {
});
});
});
describe('#isEqual', () => {
const layerId = '8bd66b66-aba3-49fb-9ff2-4bf83f2be08e';
const persistableState: IndexPatternPersistedState = {
layers: {
[layerId]: {
columns: {
'fa649155-d7f5-49d9-af26-508287431244': {
label: 'Count of records',
dataType: 'number',
operationType: 'count',
isBucketed: false,
scale: 'ratio',
sourceField: 'Records',
},
},
columnOrder: ['fa649155-d7f5-49d9-af26-508287431244'],
incompleteColumns: {},
},
},
};
const currentIndexPatternReference = {
id: 'some-id',
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
};
const references1: SavedObjectReference[] = [
currentIndexPatternReference,
{
id: 'some-id',
name: 'indexpattern-datasource-layer-8bd66b66-aba3-49fb-9ff2-4bf83f2be08e',
type: 'index-pattern',
},
];
const references2: SavedObjectReference[] = [
currentIndexPatternReference,
{
id: 'some-DIFFERENT-id',
name: 'indexpattern-datasource-layer-8bd66b66-aba3-49fb-9ff2-4bf83f2be08e',
type: 'index-pattern',
},
];
it('should be false if datasource states are using different data views', () => {
expect(
indexPatternDatasource.isEqual(persistableState, references1, persistableState, references2)
).toBe(false);
});
it('should be false if datasource states differ', () => {
const differentPersistableState = cloneDeep(persistableState);
differentPersistableState.layers[layerId].columnOrder = ['something else'];
expect(
indexPatternDatasource.isEqual(
persistableState,
references1,
differentPersistableState,
references1
)
).toBe(false);
});
it('should be true if datasource states are identical and they refer to the same data view', () => {
expect(
indexPatternDatasource.isEqual(persistableState, references1, persistableState, references1)
).toBe(true);
});
});
});

View file

@ -12,6 +12,7 @@ import type { CoreStart, SavedObjectReference } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import type { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import type { FieldFormatsStart } from 'src/plugins/field_formats/public';
import { isEqual } from 'lodash';
import type { IndexPatternFieldEditorStart } from '../../../../../src/plugins/data_view_field_editor/public';
import type {
DatasourceDimensionEditorProps,
@ -27,6 +28,7 @@ import {
changeIndexPattern,
changeLayerIndexPattern,
extractReferences,
injectReferences,
} from './loader';
import { toExpression } from './to_expression';
import {
@ -545,6 +547,16 @@ export function getIndexPatternDatasource({
})
);
},
isEqual: (
persistableState1: IndexPatternPersistedState,
references1: SavedObjectReference[],
persistableState2: IndexPatternPersistedState,
references2: SavedObjectReference[]
) =>
isEqual(
injectReferences(persistableState1, references1),
injectReferences(persistableState2, references2)
),
};
return indexPatternDatasource;

View file

@ -7,7 +7,7 @@
import { Observable, Subject } from 'rxjs';
import moment from 'moment';
import { DataPublicPluginStart, esFilters } from '../../../../../src/plugins/data/public';
import { DataPublicPluginStart, esFilters, Filter } from '../../../../../src/plugins/data/public';
function createMockTimefilter() {
const unsubscribe = jest.fn();
@ -90,6 +90,19 @@ export function mockDataPlugin(
filters = [];
subscriber();
},
inject: (filtersIn: Filter[]) => {
return filtersIn.map((filter) => ({
...filter,
meta: { ...filter.meta, index: 'injected!' },
}));
},
extract: (filtersIn: Filter[]) => {
const state = filtersIn.map((filter) => ({
...filter,
meta: { ...filter.meta, index: 'extracted!' },
}));
return { state, references: [] };
},
};
}
function createMockQueryString() {

View file

@ -51,6 +51,7 @@ export function createMockDatasource(id: string): DatasourceMock {
checkIntegrity: jest.fn((_state) => []),
isTimeBased: jest.fn(),
isValidColumn: jest.fn(),
isEqual: jest.fn(),
};
}

View file

@ -40,7 +40,7 @@ export const defaultDoc = {
visualizationType: 'testVis',
state: {
query: 'kuery',
filters: [{ query: { match_phrase: { src: 'test' } } }],
filters: [{ query: { match_phrase: { src: 'test' } }, meta: { index: 'index-pattern-0' } }],
datasourceStates: {
testDatasource: 'datasource',
},

View file

@ -1,112 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Filter } from '@kbn/es-query';
import { extractFilterReferences, injectFilterReferences } from './filter_references';
import { FilterStateStore } from 'src/plugins/data/common';
describe('filter saved object references', () => {
const filters: Filter[] = [
{
$state: { store: FilterStateStore.APP_STATE },
meta: {
alias: null,
disabled: false,
index: '90943e30-9a47-11e8-b64d-95841ca0b247',
key: 'geo.src',
negate: true,
params: { query: 'CN' },
type: 'phrase',
},
query: { match_phrase: { 'geo.src': 'CN' } },
},
{
$state: { store: FilterStateStore.APP_STATE },
meta: {
alias: null,
disabled: false,
index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f',
key: 'geoip.country_iso_code',
negate: true,
params: { query: 'US' },
type: 'phrase',
},
query: { match_phrase: { 'geoip.country_iso_code': 'US' } },
},
];
it('should create two index-pattern references', () => {
const { references } = extractFilterReferences(filters);
expect(references).toMatchInlineSnapshot(`
Array [
Object {
"id": "90943e30-9a47-11e8-b64d-95841ca0b247",
"name": "filter-index-pattern-0",
"type": "index-pattern",
},
Object {
"id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
"name": "filter-index-pattern-1",
"type": "index-pattern",
},
]
`);
});
it('should remove index and value from persistable filter', () => {
const { persistableFilters } = extractFilterReferences([
{ ...filters[0], meta: { ...filters[0].meta, value: 'CN' } },
{ ...filters[1], meta: { ...filters[1].meta, value: 'US' } },
]);
expect(persistableFilters.length).toBe(2);
persistableFilters.forEach((filter) => {
expect(filter.meta.hasOwnProperty('index')).toBe(false);
expect(filter.meta.hasOwnProperty('value')).toBe(false);
});
});
it('should restore the same filter after extracting and injecting', () => {
const { persistableFilters, references } = extractFilterReferences(filters);
expect(injectFilterReferences(persistableFilters, references)).toEqual(filters);
});
it('should ignore other references', () => {
const { persistableFilters, references } = extractFilterReferences(filters);
expect(
injectFilterReferences(persistableFilters, [
{ type: 'index-pattern', id: '1234', name: 'some other index pattern' },
...references,
])
).toEqual(filters);
});
it('should inject other ids if references change', () => {
const { persistableFilters, references } = extractFilterReferences(filters);
expect(
injectFilterReferences(
persistableFilters,
references.map((reference, index) => ({ ...reference, id: `overwritten-id-${index}` }))
)
).toEqual([
{
...filters[0],
meta: {
...filters[0].meta,
index: 'overwritten-id-0',
},
},
{
...filters[1],
meta: {
...filters[1].meta,
index: 'overwritten-id-1',
},
},
]);
});
});

View file

@ -1,62 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Filter } from '@kbn/es-query';
import { SavedObjectReference } from 'kibana/public';
import { PersistableFilter } from '../../common';
export function extractFilterReferences(filters: Filter[]): {
persistableFilters: PersistableFilter[];
references: SavedObjectReference[];
} {
const references: SavedObjectReference[] = [];
const persistableFilters = filters.map((filterRow, i) => {
if (!filterRow.meta || !filterRow.meta.index) {
return filterRow;
}
const refName = `filter-index-pattern-${i}`;
references.push({
name: refName,
type: 'index-pattern',
id: filterRow.meta.index,
});
const newFilter = {
...filterRow,
meta: {
...filterRow.meta,
indexRefName: refName,
},
};
// remove index because it's specified by indexRefName
delete newFilter.meta.index;
// remove value because it can't be persisted
delete newFilter.meta.value;
return newFilter;
});
return { persistableFilters, references };
}
export function injectFilterReferences(
filters: PersistableFilter[],
references: SavedObjectReference[]
) {
return filters.map((filterRow) => {
if (!filterRow.meta || !filterRow.meta.indexRefName) {
return filterRow as Filter;
}
const { indexRefName, ...metaRest } = filterRow.meta;
const reference = references.find((ref) => ref.name === indexRefName);
if (!reference) {
throw new Error(`Could not find reference for ${indexRefName}`);
}
return {
...filterRow,
meta: { ...metaRest, index: reference.id },
};
});
}

View file

@ -6,5 +6,4 @@
*/
export * from './saved_object_store';
export * from './filter_references';
export { checkForDuplicateTitle } from './saved_objects_utils';

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { Filter } from '@kbn/es-query';
import {
SavedObjectAttributes,
SavedObjectsClientContract,
@ -12,7 +13,7 @@ import {
ResolvedSimpleSavedObject,
} from 'kibana/public';
import { Query } from '../../../../../src/plugins/data/public';
import { DOC_TYPE, PersistableFilter } from '../../common';
import { DOC_TYPE } from '../../common';
import { LensSavedObjectAttributes } from '../async_services';
export interface Document {
@ -29,7 +30,7 @@ export interface Document {
activePaletteId: string;
state?: unknown;
};
filters: PersistableFilter[];
filters: Filter[];
};
references: SavedObjectReference[];
}

View file

@ -212,6 +212,7 @@ export class LensPlugin {
timefilter: plugins.data.query.timefilter.timefilter,
expressionRenderer: plugins.expressions.ReactExpressionRenderer,
documentToExpression: this.editorFrameService!.documentToExpression,
injectFilterReferences: data.query.filterManager.inject,
visualizationMap,
indexPatternService: plugins.data.indexPatterns,
uiActions: plugins.uiActions,

View file

@ -37,6 +37,9 @@ Object {
},
"filters": Array [
Object {
"meta": Object {
"index": "index-pattern-0",
},
"query": Object {
"match_phrase": Object {
"src": "test",

View file

@ -16,7 +16,7 @@ import { getInitialDatasourceId } from '../../utils';
import { initializeDatasources } from '../../editor_frame_service/editor_frame';
import { LensAppServices } from '../../app_plugin/types';
import { getEditPath, getFullPath, LENS_EMBEDDABLE_TYPE } from '../../../common/constants';
import { Document, injectFilterReferences } from '../../persistence';
import { Document } from '../../persistence';
export const getPersisted = async ({
initialInput,
@ -162,7 +162,7 @@ export function loadInitial(
{}
);
const filters = injectFilterReferences(doc.state.filters, doc.references);
const filters = data.query.filterManager.inject(doc.state.filters, doc.references);
// Don't overwrite any pinned filters
data.query.filterManager.setAppFilters(filters);

View file

@ -224,7 +224,7 @@ describe('Initializing the store', () => {
});
expect(deps.lensServices.data.query.filterManager.setAppFilters).toHaveBeenCalledWith([
{ query: { match_phrase: { src: 'test' } } },
{ query: { match_phrase: { src: 'test' } }, meta: { index: 'injected!' } },
]);
expect(store.getState()).toEqual({

View file

@ -7,8 +7,8 @@
import { createSelector } from '@reduxjs/toolkit';
import { SavedObjectReference } from 'kibana/server';
import { FilterManager } from 'src/plugins/data/public';
import { LensState } from './types';
import { extractFilterReferences } from '../persistence';
import { Datasource, DatasourceMap, VisualizationMap } from '../types';
import { getDatasourceLayers } from '../editor_frame_service/editor_frame';
@ -43,13 +43,10 @@ export const selectExecutionContextSearch = createSelector(selectExecutionContex
filters: res.filters,
}));
const selectDatasourceMap = (state: LensState, datasourceMap: DatasourceMap) => datasourceMap;
const selectInjectedDependencies = (_state: LensState, dependencies: unknown) => dependencies;
const selectVisualizationMap = (
state: LensState,
datasourceMap: DatasourceMap,
visualizationMap: VisualizationMap
) => visualizationMap;
// use this type to cast selectInjectedDependencies to require whatever outside dependencies the selector needs
type SelectInjectedDependenciesFunction<T> = (state: LensState, dependencies: T) => T;
export const selectSavedObjectFormat = createSelector(
[
@ -59,8 +56,11 @@ export const selectSavedObjectFormat = createSelector(
selectQuery,
selectFilters,
selectActiveDatasourceId,
selectDatasourceMap,
selectVisualizationMap,
selectInjectedDependencies as SelectInjectedDependenciesFunction<{
datasourceMap: DatasourceMap;
visualizationMap: VisualizationMap;
extractFilterReferences: FilterManager['extract'];
}>,
],
(
persistedDoc,
@ -69,8 +69,7 @@ export const selectSavedObjectFormat = createSelector(
query,
filters,
activeDatasourceId,
datasourceMap,
visualizationMap
{ datasourceMap, visualizationMap, extractFilterReferences }
) => {
const activeVisualization =
visualization.state && visualization.activeId && visualizationMap[visualization.activeId];
@ -101,7 +100,8 @@ export const selectSavedObjectFormat = createSelector(
references.push(...savedObjectReferences);
});
const { persistableFilters, references: filterReferences } = extractFilterReferences(filters);
const { state: persistableFilters, references: filterReferences } =
extractFilterReferences(filters);
references.push(...filterReferences);
@ -140,12 +140,19 @@ export const selectAreDatasourcesLoaded = createSelector(
);
export const selectDatasourceLayers = createSelector(
[selectDatasourceStates, selectDatasourceMap],
[
selectDatasourceStates,
selectInjectedDependencies as SelectInjectedDependenciesFunction<DatasourceMap>,
],
(datasourceStates, datasourceMap) => getDatasourceLayers(datasourceStates, datasourceMap)
);
export const selectFramePublicAPI = createSelector(
[selectDatasourceStates, selectActiveData, selectDatasourceMap],
[
selectDatasourceStates,
selectActiveData,
selectInjectedDependencies as SelectInjectedDependenciesFunction<DatasourceMap>,
],
(datasourceStates, activeData, datasourceMap) => {
return {
datasourceLayers: getDatasourceLayers(datasourceStates, datasourceMap),

View file

@ -289,6 +289,15 @@ export interface Datasource<T = unknown, P = unknown> {
* Given the current state layer and a columnId will verify if the column configuration has errors
*/
isValidColumn: (state: T, layerId: string, columnId: string) => boolean;
/**
* Are these datasources equivalent?
*/
isEqual: (
persistableState1: P,
references1: SavedObjectReference[],
persistableState2: P,
references2: SavedObjectReference[]
) => boolean;
}
export interface DatasourceFixAction<T> {

View file

@ -1,24 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import semverGte from 'semver/functions/gte';
import { lensEmbeddableFactory } from './lens_embeddable_factory';
import { migrations } from '../migrations/saved_object_migrations';
describe('saved object migrations and embeddable migrations', () => {
test('should have same versions registered (>7.13.0)', () => {
const savedObjectMigrationVersions = Object.keys(migrations).filter((version) => {
return semverGte(version, '7.13.1');
});
const embeddableMigrationVersions = lensEmbeddableFactory()?.migrations;
if (embeddableMigrationVersions) {
expect(savedObjectMigrationVersions.sort()).toEqual(
Object.keys(embeddableMigrationVersions).sort()
);
}
});
});

View file

@ -1,67 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server';
import type { SerializableRecord } from '@kbn/utility-types';
import { DOC_TYPE } from '../../common';
import {
commonMakeReversePaletteAsCustom,
commonRemoveTimezoneDateHistogramParam,
commonRenameOperationsForFormula,
commonUpdateVisLayerType,
} from '../migrations/common_migrations';
import {
LensDocShape713,
LensDocShape715,
LensDocShapePre712,
VisState716,
VisStatePre715,
} from '../migrations/types';
import { extract, inject } from '../../common/embeddable_factory';
export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => {
return {
id: DOC_TYPE,
migrations: {
// This migration is run in 7.13.1 for `by value` panels because the 7.13 release window was missed.
'7.13.1': (state) => {
const lensState = state as unknown as { attributes: LensDocShapePre712 };
const migratedLensState = commonRenameOperationsForFormula(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'7.14.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape713 };
const migratedLensState = commonRemoveTimezoneDateHistogramParam(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'7.15.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape715<VisStatePre715> };
const migratedLensState = commonUpdateVisLayerType(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'7.16.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape715<VisState716> };
const migratedLensState = commonMakeReversePaletteAsCustom(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
},
extract,
inject,
};
};

View file

@ -0,0 +1,74 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import semverGte from 'semver/functions/gte';
import { makeLensEmbeddableFactory } from './make_lens_embeddable_factory';
import { getAllMigrations } from '../migrations/saved_object_migrations';
import { Filter } from '@kbn/es-query';
describe('embeddable migrations', () => {
test('should have all saved object migrations versions (>7.13.0)', () => {
const savedObjectMigrationVersions = Object.keys(getAllMigrations({})).filter((version) => {
return semverGte(version, '7.13.1');
});
const embeddableMigrationVersions = makeLensEmbeddableFactory({})()?.migrations;
if (embeddableMigrationVersions) {
expect(savedObjectMigrationVersions.sort()).toEqual(
Object.keys(embeddableMigrationVersions).sort()
);
}
});
test('should properly apply a filter migration within a lens visualization', () => {
const migrationVersion = 'some-version';
const lensVisualizationDoc = {
attributes: {
state: {
filters: [
{
filter: 1,
migrated: false,
},
{
filter: 2,
migrated: false,
},
],
},
},
};
const embeddableMigrationVersions = makeLensEmbeddableFactory({
[migrationVersion]: (filters: Filter[]) => {
return filters.map((filterState) => ({
...filterState,
migrated: true,
}));
},
})()?.migrations;
const migratedLensDoc = embeddableMigrationVersions?.[migrationVersion](lensVisualizationDoc);
expect(migratedLensDoc).toEqual({
attributes: {
state: {
filters: [
{
filter: 1,
migrated: true,
},
{
filter: 2,
migrated: true,
},
],
},
},
});
});
});

View file

@ -0,0 +1,82 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server';
import type { SerializableRecord } from '@kbn/utility-types';
import {
mergeMigrationFunctionMaps,
MigrateFunctionsObject,
} from '../../../../../src/plugins/kibana_utils/common';
import { DOC_TYPE } from '../../common';
import {
commonMakeReversePaletteAsCustom,
commonRemoveTimezoneDateHistogramParam,
commonRenameFilterReferences,
commonRenameOperationsForFormula,
commonUpdateVisLayerType,
getLensFilterMigrations,
} from '../migrations/common_migrations';
import {
LensDocShape713,
LensDocShape715,
LensDocShapePre712,
VisState716,
VisStatePre715,
} from '../migrations/types';
import { extract, inject } from '../../common/embeddable_factory';
export const makeLensEmbeddableFactory =
(filterMigrations: MigrateFunctionsObject) => (): EmbeddableRegistryDefinition => {
return {
id: DOC_TYPE,
migrations: mergeMigrationFunctionMaps(getLensFilterMigrations(filterMigrations), {
// This migration is run in 7.13.1 for `by value` panels because the 7.13 release window was missed.
'7.13.1': (state) => {
const lensState = state as unknown as { attributes: LensDocShapePre712 };
const migratedLensState = commonRenameOperationsForFormula(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'7.14.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape713 };
const migratedLensState = commonRemoveTimezoneDateHistogramParam(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'7.15.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape715<VisStatePre715> };
const migratedLensState = commonUpdateVisLayerType(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'7.16.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape715<VisState716> };
const migratedLensState = commonMakeReversePaletteAsCustom(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
'8.1.0': (state) => {
const lensState = state as unknown as { attributes: LensDocShape715<VisState716> };
const migratedLensState = commonRenameFilterReferences(lensState.attributes);
return {
...lensState,
attributes: migratedLensState,
} as unknown as SerializableRecord;
},
}),
extract,
inject,
};
};

View file

@ -0,0 +1,44 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Filter } from '@kbn/es-query';
import { getLensFilterMigrations } from './common_migrations';
describe('Lens migrations', () => {
describe('applying filter migrations', () => {
it('creates a filter migrations map that works on a lens visualization', () => {
const filterMigrations = {
'1.1': (filters: Filter[]) => filters.map((filter) => ({ ...filter, version: '1.1' })),
'2.2': (filters: Filter[]) => filters.map((filter) => ({ ...filter, version: '2.2' })),
'3.3': (filters: Filter[]) => filters.map((filter) => ({ ...filter, version: '3.3' })),
};
const lensVisualizationSavedObject = {
attributes: {
state: {
filters: [{}, {}],
},
},
};
const migrationMap = getLensFilterMigrations(filterMigrations);
expect(migrationMap['1.1'](lensVisualizationSavedObject).attributes.state.filters).toEqual([
{ version: '1.1' },
{ version: '1.1' },
]);
expect(migrationMap['2.2'](lensVisualizationSavedObject).attributes.state.filters).toEqual([
{ version: '2.2' },
{ version: '2.2' },
]);
expect(migrationMap['3.3'](lensVisualizationSavedObject).attributes.state.filters).toEqual([
{ version: '3.3' },
{ version: '3.3' },
]);
});
});
});

View file

@ -7,6 +7,11 @@
import { cloneDeep } from 'lodash';
import { PaletteOutput } from 'src/plugins/charts/common';
import { Filter } from '@kbn/es-query';
import {
MigrateFunction,
MigrateFunctionsObject,
} from '../../../../../src/plugins/kibana_utils/common';
import {
LensDocShapePre712,
OperationTypePre712,
@ -19,6 +24,7 @@ import {
VisState716,
} from './types';
import { CustomPaletteParams, layerTypes } from '../../common';
import { LensDocShape } from './saved_object_migrations';
export const commonRenameOperationsForFormula = (
attributes: LensDocShapePre712
@ -155,3 +161,40 @@ export const commonMakeReversePaletteAsCustom = (
}
return newAttributes;
};
export const commonRenameFilterReferences = (attributes: LensDocShape715<VisState716>) => {
const newAttributes = cloneDeep(attributes);
for (const filter of newAttributes.state.filters) {
filter.meta.index = filter.meta.indexRefName;
delete filter.meta.indexRefName;
}
return newAttributes;
};
const getApplyFilterMigrationToLens = (filterMigration: MigrateFunction<Filter[]>) => {
return (savedObject: { attributes: LensDocShape }) => {
return {
...savedObject,
attributes: {
...savedObject.attributes,
state: {
...savedObject.attributes.state,
filters: filterMigration(savedObject.attributes.state.filters as unknown as Filter[]),
},
},
};
};
};
/**
* This creates a migration map that applies filter migrations to Lens visualizations
*/
export const getLensFilterMigrations = (filterMigrations: MigrateFunctionsObject) => {
const migrationMap: MigrateFunctionsObject = {};
for (const version in filterMigrations) {
if (filterMigrations.hasOwnProperty(version)) {
migrationMap[version] = getApplyFilterMigrationToLens(filterMigrations[version]);
}
}
return migrationMap;
};

View file

@ -6,7 +6,7 @@
*/
import { cloneDeep } from 'lodash';
import { migrations, LensDocShape } from './saved_object_migrations';
import { getAllMigrations, LensDocShape } from './saved_object_migrations';
import {
SavedObjectMigrationContext,
SavedObjectMigrationFn,
@ -15,8 +15,10 @@ import {
import { LensDocShape715, VisState716, VisStatePost715, VisStatePre715 } from './types';
import { CustomPaletteParams, layerTypes } from '../../common';
import { PaletteOutput } from 'src/plugins/charts/common';
import { Filter } from '@kbn/es-query';
describe('Lens migrations', () => {
const migrations = getAllMigrations({});
describe('7.7.0 missing dimensions in XY', () => {
const context = {} as SavedObjectMigrationContext;
@ -1404,4 +1406,162 @@ describe('Lens migrations', () => {
]);
});
});
describe('8.1.0 update filter reference schema', () => {
const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext;
const example = {
type: 'lens',
id: 'mocked-saved-object-id',
attributes: {
savedObjectId: '1',
title: 'MyRenamedOps',
description: '',
visualizationType: null,
state: {
datasourceMetaData: {
filterableIndexPatterns: [],
},
datasourceStates: {
indexpattern: {
currentIndexPatternId: 'logstash-*',
layers: {
'2': {
columns: {
'3': {
label: '@timestamp',
dataType: 'date',
operationType: 'date_histogram',
sourceField: '@timestamp',
isBucketed: true,
scale: 'interval',
params: { interval: 'auto', timeZone: 'Europe/Berlin' },
},
'4': {
label: '@timestamp',
dataType: 'date',
operationType: 'date_histogram',
sourceField: '@timestamp',
isBucketed: true,
scale: 'interval',
params: { interval: 'auto' },
},
'5': {
label: '@timestamp',
dataType: 'date',
operationType: 'my_unexpected_operation',
isBucketed: true,
scale: 'interval',
params: { timeZone: 'do not delete' },
},
},
columnOrder: ['3', '4', '5'],
},
},
},
},
visualization: {},
query: { query: '', language: 'kuery' },
filters: [
{
meta: {
alias: null,
negate: false,
disabled: false,
type: 'phrase',
key: 'geo.src',
params: { query: 'US' },
indexRefName: 'filter-index-pattern-0',
},
query: { match_phrase: { 'geo.src': 'US' } },
$state: { store: 'appState' },
},
{
meta: {
alias: null,
negate: false,
disabled: false,
type: 'phrase',
key: 'client_ip',
params: { query: '1234.5344.2243.3245' },
indexRefName: 'filter-index-pattern-2',
},
query: { match_phrase: { client_ip: '1234.5344.2243.3245' } },
$state: { store: 'appState' },
},
],
},
},
} as unknown as SavedObjectUnsanitizedDoc<LensDocShape715<VisState716>>;
it('should rename indexRefName to index in filters metadata', () => {
const expectedFilters = example.attributes.state.filters.map((filter) => {
return {
...filter,
meta: {
...filter.meta,
index: filter.meta.indexRefName,
indexRefName: undefined,
},
};
});
const result = migrations['8.1.0'](example, context) as ReturnType<
SavedObjectMigrationFn<LensDocShape, LensDocShape>
>;
expect(result.attributes.state.filters).toEqual(expectedFilters);
});
});
test('should properly apply a filter migration within a lens visualization', () => {
const migrationVersion = 'some-version';
const lensVisualizationDoc = {
attributes: {
state: {
filters: [
{
filter: 1,
migrated: false,
},
{
filter: 2,
migrated: false,
},
],
},
},
};
const migrationFunctionsObject = getAllMigrations({
[migrationVersion]: (filters: Filter[]) => {
return filters.map((filterState) => ({
...filterState,
migrated: true,
}));
},
});
const migratedLensDoc = migrationFunctionsObject[migrationVersion](
lensVisualizationDoc as SavedObjectUnsanitizedDoc,
{} as SavedObjectMigrationContext
);
expect(migratedLensDoc).toEqual({
attributes: {
state: {
filters: [
{
filter: 1,
migrated: true,
},
{
filter: 2,
migrated: true,
},
],
},
},
});
});
});

View file

@ -5,16 +5,18 @@
* 2.0.
*/
import { cloneDeep } from 'lodash';
import { cloneDeep, mergeWith } from 'lodash';
import { fromExpression, toExpression, Ast, ExpressionFunctionAST } from '@kbn/interpreter';
import {
SavedObjectMigrationMap,
SavedObjectMigrationFn,
SavedObjectReference,
SavedObjectUnsanitizedDoc,
SavedObjectMigrationContext,
} from 'src/core/server';
import { Filter } from '@kbn/es-query';
import { Query } from 'src/plugins/data/public';
import { MigrateFunctionsObject } from '../../../../../src/plugins/kibana_utils/common';
import { PersistableFilter } from '../../common';
import {
LensDocShapePost712,
@ -31,6 +33,8 @@ import {
commonRemoveTimezoneDateHistogramParam,
commonUpdateVisLayerType,
commonMakeReversePaletteAsCustom,
commonRenameFilterReferences,
getLensFilterMigrations,
} from './common_migrations';
interface LensDocShapePre710<VisualizationState = unknown> {
@ -440,7 +444,15 @@ const moveDefaultReversedPaletteToCustom: SavedObjectMigrationFn<
return { ...newDoc, attributes: commonMakeReversePaletteAsCustom(newDoc.attributes) };
};
export const migrations: SavedObjectMigrationMap = {
const renameFilterReferences: SavedObjectMigrationFn<
LensDocShape715<VisState716>,
LensDocShape715<VisState716>
> = (doc) => {
const newDoc = cloneDeep(doc);
return { ...newDoc, attributes: commonRenameFilterReferences(newDoc.attributes) };
};
const lensMigrations: SavedObjectMigrationMap = {
'7.7.0': removeInvalidAccessors,
// The order of these migrations matter, since the timefield migration relies on the aggConfigs
// sitting directly on the esaggs as an argument and not a nested function (which lens_auto_date was).
@ -453,4 +465,25 @@ export const migrations: SavedObjectMigrationMap = {
'7.14.0': removeTimezoneDateHistogramParam,
'7.15.0': addLayerTypeToVisualization,
'7.16.0': moveDefaultReversedPaletteToCustom,
'8.1.0': renameFilterReferences,
};
export const mergeSavedObjectMigrationMaps = (
obj1: SavedObjectMigrationMap,
obj2: SavedObjectMigrationMap
): SavedObjectMigrationMap => {
const customizer = (objValue: SavedObjectMigrationFn, srcValue: SavedObjectMigrationFn) => {
if (!srcValue || !objValue) {
return srcValue || objValue;
}
return (state: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext) =>
objValue(srcValue(state, context), context);
};
return mergeWith({ ...obj1 }, obj2, customizer);
};
export const getAllMigrations = (
filterMigrations: MigrateFunctionsObject
): SavedObjectMigrationMap =>
mergeSavedObjectMigrationMaps(lensMigrations, getLensFilterMigrations(filterMigrations));

View file

@ -8,7 +8,7 @@
import type { PaletteOutput } from 'src/plugins/charts/common';
import { Filter } from '@kbn/es-query';
import { Query } from 'src/plugins/data/public';
import type { CustomPaletteParams, LayerType } from '../../common';
import type { CustomPaletteParams, LayerType, PersistableFilter } from '../../common';
export type OperationTypePre712 =
| 'avg'
@ -191,10 +191,17 @@ export interface LensDocShape715<VisualizationState = unknown> {
};
visualization: VisualizationState;
query: Query;
filters: Filter[];
filters: PersistableFilter[];
};
}
export type LensDocShape810<VisualizationState = unknown> = Omit<
LensDocShape715<VisualizationState>,
'filters'
> & {
filters: Filter[];
};
export type VisState716 =
// Datatable
| {

View file

@ -7,7 +7,10 @@
import { Plugin, CoreSetup, CoreStart, PluginInitializerContext, Logger } from 'src/core/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { PluginStart as DataPluginStart } from 'src/plugins/data/server';
import {
PluginStart as DataPluginStart,
PluginSetup as DataPluginSetup,
} from 'src/plugins/data/server';
import { ExpressionsServerSetup } from 'src/plugins/expressions/server';
import { FieldFormatsStart } from 'src/plugins/field_formats/server';
import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server';
@ -19,14 +22,15 @@ import {
} from './usage';
import { setupSavedObjects } from './saved_objects';
import { EmbeddableSetup } from '../../../../src/plugins/embeddable/server';
import { lensEmbeddableFactory } from './embeddable/lens_embeddable_factory';
import { setupExpressions } from './expressions';
import { makeLensEmbeddableFactory } from './embeddable/make_lens_embeddable_factory';
export interface PluginSetupContract {
usageCollection?: UsageCollectionSetup;
taskManager?: TaskManagerSetupContract;
embeddable: EmbeddableSetup;
expressions: ExpressionsServerSetup;
data: DataPluginSetup;
}
export interface PluginStartContract {
@ -36,7 +40,7 @@ export interface PluginStartContract {
}
export interface LensServerPluginSetup {
lensEmbeddableFactory: typeof lensEmbeddableFactory;
lensEmbeddableFactory: ReturnType<typeof makeLensEmbeddableFactory>;
}
export class LensServerPlugin implements Plugin<LensServerPluginSetup, {}, {}, {}> {
@ -47,7 +51,8 @@ export class LensServerPlugin implements Plugin<LensServerPluginSetup, {}, {}, {
}
setup(core: CoreSetup<PluginStartContract>, plugins: PluginSetupContract) {
setupSavedObjects(core);
const filterMigrations = plugins.data.query.filterManager.getAllMigrations();
setupSavedObjects(core, filterMigrations);
setupRoutes(core, this.initializerContext.logger.get());
setupExpressions(core, plugins.expressions);
@ -61,6 +66,7 @@ export class LensServerPlugin implements Plugin<LensServerPluginSetup, {}, {}, {
initializeLensTelemetry(this.telemetryLogger, core, plugins.taskManager);
}
const lensEmbeddableFactory = makeLensEmbeddableFactory(filterMigrations);
plugins.embeddable.registerEmbeddableFactory(lensEmbeddableFactory());
return {
lensEmbeddableFactory,

View file

@ -6,10 +6,11 @@
*/
import { CoreSetup } from 'kibana/server';
import { MigrateFunctionsObject } from '../../../../src/plugins/kibana_utils/common';
import { getEditPath } from '../common';
import { migrations } from './migrations/saved_object_migrations';
import { getAllMigrations } from './migrations/saved_object_migrations';
export function setupSavedObjects(core: CoreSetup) {
export function setupSavedObjects(core: CoreSetup, filterMigrations: MigrateFunctionsObject) {
core.savedObjects.registerType({
name: 'lens',
hidden: false,
@ -25,7 +26,7 @@ export function setupSavedObjects(core: CoreSetup) {
uiCapabilitiesPath: 'visualize.show',
}),
},
migrations,
migrations: getAllMigrations(filterMigrations),
mappings: {
properties: {
title: {

View file

@ -157,7 +157,7 @@ function getLensAttributes(
{
$state: { store: FilterStateStore.APP_STATE },
meta: {
indexRefName: 'filter-index-pattern-0',
index: 'filter-index-pattern-0',
negate: false,
alias: null,
disabled: false,
@ -180,7 +180,7 @@ function getLensAttributes(
meta: {
alias: 'agent IDs',
disabled: false,
indexRefName: 'filter-index-pattern-0',
index: 'filter-index-pattern-0',
key: 'query',
negate: false,
type: 'custom',