diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index 62b63674e6f815..1e749cb67c4723 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -33,6 +33,9 @@ contains the following properties: `attributes` (required):: (object) The data to persist +`references` (optional):: + (array) An array of objects with `name`, `id`, and `type` properties that describe the other saved objects this object references. The `name` can be used in the attributes to refer to the other saved object, but never the `id`, which may be updated automatically in the future during migrations or import/export. + `version` (optional):: (number) Enables specifying a version diff --git a/docs/api/saved-objects/create.asciidoc b/docs/api/saved-objects/create.asciidoc index 7631dd296c2b3e..c4a2cf260f7d91 100644 --- a/docs/api/saved-objects/create.asciidoc +++ b/docs/api/saved-objects/create.asciidoc @@ -33,6 +33,8 @@ Note: You cannot access this endpoint via the Console in Kibana. `attributes` (required):: (object) The data to persist +`references` (optional):: + (array) An array of objects with `name`, `id`, and `type` properties that describe the other saved objects this object references. The `name` can be used in the attributes to refer to the other saved object, but never the `id`, which may be updated automatically in the future during migrations or import/export. ==== Examples diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index 8a866c9de71d76..43fea5b5116c65 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -29,6 +29,8 @@ Note: You cannot access this endpoint via the Console in Kibana. (array|string) The fields to return in the response `sort_field` (optional):: (string) The field on which the response will be sorted +`has_reference` (optional):: + (object) Filters to objects having a relationship with the type and id combination [NOTE] ============================================== diff --git a/docs/api/saved-objects/update.asciidoc b/docs/api/saved-objects/update.asciidoc index 2c61f2e66b4b08..092128040d3ebc 100644 --- a/docs/api/saved-objects/update.asciidoc +++ b/docs/api/saved-objects/update.asciidoc @@ -26,6 +26,8 @@ Note: You cannot access this endpoint via the Console in Kibana. `attributes` (required):: (object) The data to persist +`references` (optional):: + (array) An array of objects with `name`, `id`, and `type` properties that describe the other saved objects this object references. The `name` can be used in the attributes to refer to the other saved object, but never the `id`, which may be updated automatically in the future during migrations or import/export. ==== Examples diff --git a/src/legacy/core_plugins/kibana/mappings.json b/src/legacy/core_plugins/kibana/mappings.json index 155742b15c8a7c..243d49c448c6d4 100644 --- a/src/legacy/core_plugins/kibana/mappings.json +++ b/src/legacy/core_plugins/kibana/mappings.json @@ -42,7 +42,7 @@ } } }, - "savedSearchId": { + "savedSearchRefName": { "type": "keyword" }, "title": { diff --git a/src/legacy/core_plugins/kibana/migrations.js b/src/legacy/core_plugins/kibana/migrations.js index 8dee9566f0e8a1..1d672936b389f4 100644 --- a/src/legacy/core_plugins/kibana/migrations.js +++ b/src/legacy/core_plugins/kibana/migrations.js @@ -19,9 +19,53 @@ import { cloneDeep, get, omit } from 'lodash'; +function migrateIndexPattern(doc) { + const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON'); + if (typeof searchSourceJSON !== 'string') { + return; + } + let searchSource; + try { + searchSource = JSON.parse(searchSourceJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + return; + } + if (!searchSource.index) { + return; + } + doc.references.push({ + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: searchSource.index, + }); + searchSource.indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; + delete searchSource.index; + doc.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); +} + export const migrations = { visualization: { '7.0.0': (doc) => { + // Set new "references" attribute + doc.references = doc.references || []; + + // Migrate index pattern + migrateIndexPattern(doc); + + // Migrate saved search + const savedSearchId = get(doc, 'attributes.savedSearchId'); + if (savedSearchId) { + doc.references.push({ + type: 'search', + name: 'search_0', + id: savedSearchId, + }); + doc.attributes.savedSearchRefName = 'search_0'; + delete doc.attributes.savedSearchId; + } + + // Migrate table splits try { const visState = JSON.parse(doc.attributes.visState); if (get(visState, 'type') !== 'table') { @@ -55,5 +99,52 @@ export const migrations = { throw new Error(`Failure attempting to migrate saved object '${doc.attributes.title}' - ${e}`); } } - } + }, + dashboard: { + '7.0.0': (doc) => { + // Set new "references" attribute + doc.references = doc.references || []; + // Migrate index pattern + migrateIndexPattern(doc); + // Migrate panels + const panelsJSON = get(doc, 'attributes.panelsJSON'); + if (typeof panelsJSON !== 'string') { + return doc; + } + let panels; + try { + panels = JSON.parse(panelsJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + return doc; + } + if (!Array.isArray(panels)) { + return doc; + } + panels.forEach((panel, i) => { + if (!panel.type || !panel.id) { + return; + } + panel.panelRefName = `panel_${i}`; + doc.references.push({ + name: `panel_${i}`, + type: panel.type, + id: panel.id, + }); + delete panel.type; + delete panel.id; + }); + doc.attributes.panelsJSON = JSON.stringify(panels); + return doc; + }, + }, + search: { + '7.0.0': (doc) => { + // Set new "references" attribute + doc.references = doc.references || []; + // Migrate index pattern + migrateIndexPattern(doc); + return doc; + }, + }, }; diff --git a/src/legacy/core_plugins/kibana/migrations.test.js b/src/legacy/core_plugins/kibana/migrations.test.js index dc8ddb594af02f..cafd141fc2f195 100644 --- a/src/legacy/core_plugins/kibana/migrations.test.js +++ b/src/legacy/core_plugins/kibana/migrations.test.js @@ -19,8 +19,7 @@ import { migrations } from './migrations'; -describe('table vis migrations', () => { - +describe('visualization', () => { describe('7.0.0', () => { const migrate = doc => migrations.visualization['7.0.0'](doc); const generateDoc = ({ type, aggs }) => ({ @@ -31,9 +30,296 @@ describe('table vis migrations', () => { uiStateJSON: '{}', version: 1, kibanaSavedObjectMeta: { - searchSourceJSON: '{}' - } - } + searchSourceJSON: '{}', + }, + }, + }); + + it('does not throw error on empty object', () => { + const migratedDoc = migrate({ + attributes: { + visState: '{}', + }, + }); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "visState": "{}", + }, + "references": Array [], +} +`); + }); + + it('skips errors when searchSourceJSON is null', () => { + const doc = { + id: '1', + type: 'visualization', + attributes: { + visState: '{}', + kibanaSavedObjectMeta: { + searchSourceJSON: null, + }, + savedSearchId: '123', + }, + }; + const migratedDoc = migrate(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": null, + }, + "savedSearchRefName": "search_0", + "visState": "{}", + }, + "id": "1", + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], + "type": "visualization", +} +`); + }); + + it('skips errors when searchSourceJSON is undefined', () => { + const doc = { + id: '1', + type: 'visualization', + attributes: { + visState: '{}', + kibanaSavedObjectMeta: { + searchSourceJSON: undefined, + }, + savedSearchId: '123', + }, + }; + const migratedDoc = migrate(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": undefined, + }, + "savedSearchRefName": "search_0", + "visState": "{}", + }, + "id": "1", + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], + "type": "visualization", +} +`); + }); + + it('skips error when searchSourceJSON is not a string', () => { + const doc = { + id: '1', + type: 'visualization', + attributes: { + visState: '{}', + kibanaSavedObjectMeta: { + searchSourceJSON: 123, + }, + savedSearchId: '123', + }, + }; + expect(migrate(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": 123, + }, + "savedSearchRefName": "search_0", + "visState": "{}", + }, + "id": "1", + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], + "type": "visualization", +} +`); + }); + + it('skips error when searchSourceJSON is invalid json', () => { + const doc = { + id: '1', + type: 'visualization', + attributes: { + visState: '{}', + kibanaSavedObjectMeta: { + searchSourceJSON: '{abc123}', + }, + savedSearchId: '123', + }, + }; + expect(migrate(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{abc123}", + }, + "savedSearchRefName": "search_0", + "visState": "{}", + }, + "id": "1", + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], + "type": "visualization", +} +`); + }); + + it('skips error when "index" is missing from searchSourceJSON', () => { + const doc = { + id: '1', + type: 'visualization', + attributes: { + visState: '{}', + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ bar: true }), + }, + savedSearchId: '123', + }, + }; + const migratedDoc = migrate(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"bar\\":true}", + }, + "savedSearchRefName": "search_0", + "visState": "{}", + }, + "id": "1", + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], + "type": "visualization", +} +`); + }); + + it('extracts "index" attribute from doc', () => { + const doc = { + id: '1', + type: 'visualization', + attributes: { + visState: '{}', + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ bar: true, index: 'pattern*' }), + }, + savedSearchId: '123', + }, + }; + const migratedDoc = migrate(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}", + }, + "savedSearchRefName": "search_0", + "visState": "{}", + }, + "id": "1", + "references": Array [ + Object { + "id": "pattern*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern", + }, + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], + "type": "visualization", +} +`); + }); + + it('skips extracting savedSearchId when missing', () => { + const doc = { + id: '1', + attributes: { + visState: '{}', + kibanaSavedObjectMeta: { + searchSourceJSON: '{}', + }, + }, + }; + const migratedDoc = migrate(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{}", + }, + "visState": "{}", + }, + "id": "1", + "references": Array [], +} +`); + }); + + it('extract savedSearchId from doc', () => { + const doc = { + id: '1', + attributes: { + visState: '{}', + kibanaSavedObjectMeta: { + searchSourceJSON: '{}', + }, + savedSearchId: '123', + }, + }; + const migratedDoc = migrate(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{}", + }, + "savedSearchRefName": "search_0", + "visState": "{}", + }, + "id": "1", + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], +} +`); }); it('should return a new object if vis is table and has multiple split aggs', () => { @@ -41,17 +327,17 @@ describe('table vis migrations', () => { { id: '1', schema: 'metric', - params: {} + params: {}, }, { id: '2', schema: 'split', - params: { foo: 'bar', row: true } + params: { foo: 'bar', row: true }, }, { id: '3', schema: 'split', - params: { hey: 'ya', row: false } + params: { hey: 'ya', row: false }, }, ]; const tableDoc = generateDoc({ type: 'table', aggs }); @@ -73,18 +359,18 @@ describe('table vis migrations', () => { { id: '1', schema: 'metric', - params: {} + params: {}, }, { id: '2', schema: 'split', - params: { foo: 'bar', row: true } + params: { foo: 'bar', row: true }, }, { id: '3', schema: 'segment', - params: { hey: 'ya' } - } + params: { hey: 'ya' }, + }, ]; const pieDoc = generateDoc({ type: 'pie', aggs }); const expected = pieDoc; @@ -97,13 +383,13 @@ describe('table vis migrations', () => { { id: '1', schema: 'metric', - params: {} + params: {}, }, { id: '2', schema: 'split', - params: { foo: 'bar', row: true } - } + params: { foo: 'bar', row: true }, + }, ]; const tableDoc = generateDoc({ type: 'table', aggs }); const expected = tableDoc; @@ -116,23 +402,23 @@ describe('table vis migrations', () => { { id: '1', schema: 'metric', - params: {} + params: {}, }, { id: '2', schema: 'split', - params: { foo: 'bar', row: true } + params: { foo: 'bar', row: true }, }, { id: '3', schema: 'split', - params: { hey: 'ya', row: false } + params: { hey: 'ya', row: false }, }, { id: '4', schema: 'bucket', - params: { heyyy: 'yaaa' } - } + params: { heyyy: 'yaaa' }, + }, ]; const expected = ['metric', 'split', 'bucket', 'bucket']; const migrated = migrate(generateDoc({ type: 'table', aggs })); @@ -145,18 +431,18 @@ describe('table vis migrations', () => { { id: '1', schema: 'metric', - params: {} + params: {}, }, { id: '2', schema: 'split', - params: { foo: 'bar', row: true } + params: { foo: 'bar', row: true }, }, { id: '3', schema: 'split', - params: { hey: 'ya', row: false } - } + params: { hey: 'ya', row: false }, + }, ]; const expected = [{}, { foo: 'bar', row: true }, { hey: 'ya' }]; const migrated = migrate(generateDoc({ type: 'table', aggs })); @@ -173,12 +459,555 @@ describe('table vis migrations', () => { uiStateJSON: '{}', version: 1, kibanaSavedObjectMeta: { - searchSourceJSON: '{}' - } - } + searchSourceJSON: '{}', + }, + }, }; expect(() => migrate(doc)).toThrowError(/My Vis/); }); }); +}); + +describe('dashboard', () => { + describe('7.0.0', () => { + const migration = migrations.dashboard['7.0.0']; + + test('skips error on empty object', () => { + expect(migration({})).toMatchInlineSnapshot(` +Object { + "references": Array [], +} +`); + }); + + test('skips errors when searchSourceJSON is null', () => { + const doc = { + id: '1', + type: 'dashboard', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: null, + }, + panelsJSON: + '[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]', + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": null, + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", +} +`); + }); + + test('skips errors when searchSourceJSON is undefined', () => { + const doc = { + id: '1', + type: 'dashboard', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: undefined, + }, + panelsJSON: + '[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]', + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": undefined, + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", +} +`); + }); + + test('skips error when searchSourceJSON is not a string', () => { + const doc = { + id: '1', + type: 'dashboard', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: 123, + }, + panelsJSON: + '[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]', + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": 123, + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", +} +`); + }); + + test('skips error when searchSourceJSON is invalid json', () => { + const doc = { + id: '1', + type: 'dashboard', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '{abc123}', + }, + panelsJSON: + '[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]', + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{abc123}", + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", +} +`); + }); + + test('skips error when "index" is missing from searchSourceJSON', () => { + const doc = { + id: '1', + type: 'dashboard', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ bar: true }), + }, + panelsJSON: + '[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]', + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"bar\\":true}", + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", +} +`); + }); + + test('extracts "index" attribute from doc', () => { + const doc = { + id: '1', + type: 'dashboard', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ bar: true, index: 'pattern*' }), + }, + panelsJSON: + '[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]', + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}", + }, + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "pattern*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern", + }, + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", +} +`); + }); + + test('skips error when panelsJSON is not a string', () => { + const doc = { + id: '1', + attributes: { + panelsJSON: 123, + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "panelsJSON": 123, + }, + "id": "1", + "references": Array [], +} +`); + }); + + test('skips error when panelsJSON is not valid JSON', () => { + const doc = { + id: '1', + attributes: { + panelsJSON: '{123abc}', + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "panelsJSON": "{123abc}", + }, + "id": "1", + "references": Array [], +} +`); + }); + + test('skips panelsJSON when its not an array', () => { + const doc = { + id: '1', + attributes: { + panelsJSON: '{}', + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "panelsJSON": "{}", + }, + "id": "1", + "references": Array [], +} +`); + }); + + test('skips error when a panel is missing "type" attribute', () => { + const doc = { + id: '1', + attributes: { + panelsJSON: '[{"id":"123"}]', + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "panelsJSON": "[{\\"id\\":\\"123\\"}]", + }, + "id": "1", + "references": Array [], +} +`); + }); + + test('skips error when a panel is missing "id" attribute', () => { + const doc = { + id: '1', + attributes: { + panelsJSON: '[{"type":"visualization"}]', + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "panelsJSON": "[{\\"type\\":\\"visualization\\"}]", + }, + "id": "1", + "references": Array [], +} +`); + }); + + test('extract panel references from doc', () => { + const doc = { + id: '1', + attributes: { + panelsJSON: + '[{"id":"1","type":"visualization","foo":true},{"id":"2","type":"visualization","bar":true}]', + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "panelsJSON": "[{\\"foo\\":true,\\"panelRefName\\":\\"panel_0\\"},{\\"bar\\":true,\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], +} +`); + }); + }); +}); + +describe('search', () => { + describe('7.0.0', () => { + const migration = migrations.search['7.0.0']; + test('skips errors when searchSourceJSON is null', () => { + const doc = { + id: '123', + type: 'search', + attributes: { + foo: true, + kibanaSavedObjectMeta: { + searchSourceJSON: null, + }, + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": null, + }, + }, + "id": "123", + "references": Array [], + "type": "search", +} +`); + }); + + test('skips errors when searchSourceJSON is undefined', () => { + const doc = { + id: '123', + type: 'search', + attributes: { + foo: true, + kibanaSavedObjectMeta: { + searchSourceJSON: undefined, + }, + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": undefined, + }, + }, + "id": "123", + "references": Array [], + "type": "search", +} +`); + }); + + test('skips error when searchSourceJSON is not a string', () => { + const doc = { + id: '123', + type: 'search', + attributes: { + foo: true, + kibanaSavedObjectMeta: { + searchSourceJSON: 123, + }, + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": 123, + }, + }, + "id": "123", + "references": Array [], + "type": "search", +} +`); + }); + + test('skips error when searchSourceJSON is invalid json', () => { + const doc = { + id: '123', + type: 'search', + attributes: { + foo: true, + kibanaSavedObjectMeta: { + searchSourceJSON: '{abc123}', + }, + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{abc123}", + }, + }, + "id": "123", + "references": Array [], + "type": "search", +} +`); + }); + + test('skips error when "index" is missing from searchSourceJSON', () => { + const doc = { + id: '123', + type: 'search', + attributes: { + foo: true, + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ bar: true }), + }, + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"bar\\":true}", + }, + }, + "id": "123", + "references": Array [], + "type": "search", +} +`); + }); + + test('extracts "index" attribute from doc', () => { + const doc = { + id: '123', + type: 'search', + attributes: { + foo: true, + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ bar: true, index: 'pattern*' }), + }, + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"bar\\":true,\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}", + }, + }, + "id": "123", + "references": Array [ + Object { + "id": "pattern*", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern", + }, + ], + "type": "search", +} +`); + }); + }); }); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.js b/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.js index 82cf20d9c25af9..c3e4b2c6686564 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.js @@ -22,6 +22,10 @@ import { uiModules } from 'ui/modules'; import { createDashboardEditUrl } from '../dashboard_constants'; import { createLegacyClass } from 'ui/utils/legacy_class'; import { SavedObjectProvider } from 'ui/courier'; +import { + extractReferences, + injectReferences, +} from './saved_dashboard_references'; const module = uiModules.get('app/dashboard'); @@ -37,6 +41,8 @@ module.factory('SavedDashboard', function (Private, config, i18n) { type: SavedDashboard.type, mapping: SavedDashboard.mapping, searchSource: SavedDashboard.searchsource, + extractReferences: extractReferences, + injectReferences: injectReferences, // if this is null/undefined then the SavedObject will be assigned the defaults id: id, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.js b/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.js new file mode 100644 index 00000000000000..724847337a26e3 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.js @@ -0,0 +1,78 @@ +/* + * 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 function extractReferences({ attributes, references = [] }) { + const panelReferences = []; + const panels = JSON.parse(attributes.panelsJSON); + panels.forEach((panel, i) => { + if (!panel.type) { + throw new Error(`"type" attribute is missing from panel "${i}"`); + } + if (!panel.id) { + throw new Error(`"id" attribute is missing from panel "${i}"`); + } + panel.panelRefName = `panel_${i}`; + panelReferences.push({ + name: `panel_${i}`, + type: panel.type, + id: panel.id, + }); + delete panel.type; + delete panel.id; + }); + return { + references: [ + ...references, + ...panelReferences, + ], + attributes: { + ...attributes, + panelsJSON: JSON.stringify(panels), + }, + }; +} + +export function injectReferences(savedObject, references) { + // Skip if panelsJSON is missing otherwise this will cause saved object import to fail when + // importing objects without panelsJSON. At development time of this, there is no guarantee each saved + // object has panelsJSON in all previous versions of kibana. + if (typeof savedObject.panelsJSON !== 'string') { + return; + } + const panels = JSON.parse(savedObject.panelsJSON); + // Same here, prevent failing saved object import if ever panels aren't an array. + if (!Array.isArray(panels)) { + return; + } + panels.forEach((panel) => { + if (!panel.panelRefName) { + return; + } + const reference = references.find(reference => reference.name === panel.panelRefName); + if (!reference) { + // Throw an error since "panelRefName" means the reference exists within + // "references" and in this scenario we have bad data. + throw new Error(`Could not find reference "${panel.panelRefName}"`); + } + panel.id = reference.id; + panel.type = reference.type; + delete panel.panelRefName; + }); + savedObject.panelsJSON = JSON.stringify(panels); +} diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.test.js b/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.test.js new file mode 100644 index 00000000000000..f32effd6678462 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.test.js @@ -0,0 +1,220 @@ +/* + * 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 { extractReferences, injectReferences } from './saved_dashboard_references'; + +describe('extractReferences', () => { + test('extracts references from panelsJSON', () => { + const doc = { + id: '1', + attributes: { + foo: true, + panelsJSON: JSON.stringify([ + { + type: 'visualization', + id: '1', + title: 'Title 1', + }, + { + type: 'visualization', + id: '2', + title: 'Title 2', + }, + ]), + }, + }; + const updatedDoc = extractReferences(doc); + /* eslint-disable max-len */ + expect(updatedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + "panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"panelRefName\\":\\"panel_0\\"},{\\"title\\":\\"Title 2\\",\\"panelRefName\\":\\"panel_1\\"}]", + }, + "references": Array [ + Object { + "id": "1", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "2", + "name": "panel_1", + "type": "visualization", + }, + ], +} +`); + /* eslint-enable max-len */ + }); + + test('fails when "type" attribute is missing from a panel', () => { + const doc = { + id: '1', + attributes: { + foo: true, + panelsJSON: JSON.stringify([ + { + id: '1', + title: 'Title 1', + }, + ]), + }, + }; + expect(() => extractReferences(doc)).toThrowErrorMatchingInlineSnapshot( + `"\\"type\\" attribute is missing from panel \\"0\\""` + ); + }); + + test('fails when "id" attribute is missing from a panel', () => { + const doc = { + id: '1', + attributes: { + foo: true, + panelsJSON: JSON.stringify([ + { + type: 'visualization', + title: 'Title 1', + }, + ]), + }, + }; + expect(() => extractReferences(doc)).toThrowErrorMatchingInlineSnapshot( + `"\\"id\\" attribute is missing from panel \\"0\\""` + ); + }); +}); + +describe('injectReferences', () => { + test('injects references into context', () => { + const context = { + id: '1', + foo: true, + panelsJSON: JSON.stringify([ + { + panelRefName: 'panel_0', + title: 'Title 1', + }, + { + panelRefName: 'panel_1', + title: 'Title 2', + }, + ]), + }; + const references = [ + { + name: 'panel_0', + type: 'visualization', + id: '1', + }, + { + name: 'panel_1', + type: 'visualization', + id: '2', + }, + ]; + injectReferences(context, references); + /* eslint-disable max-len */ + expect(context).toMatchInlineSnapshot(` +Object { + "foo": true, + "id": "1", + "panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\",\\"type\\":\\"visualization\\"},{\\"title\\":\\"Title 2\\",\\"id\\":\\"2\\",\\"type\\":\\"visualization\\"}]", +} +`); + /* eslint-enable max-len */ + }); + + test('skips when panelsJSON is missing', () => { + const context = { + id: '1', + foo: true, + }; + injectReferences(context, []); + expect(context).toMatchInlineSnapshot(` +Object { + "foo": true, + "id": "1", +} +`); + }); + + test('skips when panelsJSON is not an array', () => { + const context = { + id: '1', + foo: true, + panelsJSON: '{}', + }; + injectReferences(context, []); + expect(context).toMatchInlineSnapshot(` +Object { + "foo": true, + "id": "1", + "panelsJSON": "{}", +} +`); + }); + + test('skips a panel when panelRefName is missing', () => { + const context = { + id: '1', + foo: true, + panelsJSON: JSON.stringify([ + { + panelRefName: 'panel_0', + title: 'Title 1', + }, + { + title: 'Title 2', + }, + ]), + }; + const references = [ + { + name: 'panel_0', + type: 'visualization', + id: '1', + }, + ]; + injectReferences(context, references); + expect(context).toMatchInlineSnapshot(` +Object { + "foo": true, + "id": "1", + "panelsJSON": "[{\\"title\\":\\"Title 1\\",\\"id\\":\\"1\\",\\"type\\":\\"visualization\\"},{\\"title\\":\\"Title 2\\"}]", +} +`); + }); + + test(`fails when it can't find the reference in the array`, () => { + const context = { + id: '1', + foo: true, + panelsJSON: JSON.stringify([ + { + panelRefName: 'panel_0', + title: 'Title 1', + }, + ]), + }; + expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot( + `"Could not find reference \\"panel_0\\""` + ); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js index 735d1f22daedf5..6f513b46f18ec6 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/_view.js @@ -125,6 +125,14 @@ uiModules.get('apps/management') value: '{}' }); } + + if (!fieldMap.references) { + fields.push({ + name: 'references', + type: 'array', + value: '[]', + }); + } }; $scope.notFound = $routeParams.notFound; @@ -136,7 +144,10 @@ uiModules.get('apps/management') $scope.obj = obj; $scope.link = service.urlFor(obj.id); - const fields = _.reduce(obj.attributes, createField, []); + const fields = _.reduce(obj.attributes, createField, []); + // Special handling for references which isn't within "attributes" + createField(fields, obj.references, 'references'); + if (service.Class) readObjectClass(fields, service.Class); // sorts twice since we want numerical sort to prioritize over name, @@ -234,7 +245,9 @@ uiModules.get('apps/management') _.set(source, field.name, value); }); - savedObjectsClient.update(service.type, $routeParams.id, source) + const { references, ...attributes } = source; + + savedObjectsClient.update(service.type, $routeParams.id, attributes, { references }) .then(function () { return redirectHandler('updated'); }) diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/relationships.test.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/relationships.test.js index 9eb269ea464400..fabbfce0395e3b 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/relationships.test.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/relationships.test.js @@ -83,12 +83,12 @@ describe('Relationships', () => { it('should render searches normally', async () => { const props = { getRelationships: jest.fn().mockImplementation(() => ({ - indexPatterns: [ + 'index-pattern': [ { id: '1', } ], - visualizations: [ + visualization: [ { id: '2', } @@ -123,7 +123,7 @@ describe('Relationships', () => { it('should render visualizations normally', async () => { const props = { getRelationships: jest.fn().mockImplementation(() => ({ - dashboards: [ + dashboard: [ { id: '1', }, @@ -161,7 +161,7 @@ describe('Relationships', () => { it('should render dashboards normally', async () => { const props = { getRelationships: jest.fn().mockImplementation(() => ({ - visualizations: [ + visualization: [ { id: '1', }, diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js index c73f77d2687c9b..b7491583edf61e 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js @@ -148,7 +148,7 @@ class RelationshipsUI extends Component { />); break; case 'search': - if (type === 'visualizations') { + if (type === 'visualization') { calloutText = (); + if (type === 'index-pattern') { + calloutColor = 'success'; + calloutTitle = (); + calloutText = (); + } else if (type === 'search') { + calloutColor = 'success'; + calloutTitle = (); + calloutText = (); + } else { + calloutText = (); + } break; case 'index-pattern': - if (type === 'visualizations') { + if (type === 'visualization') { calloutText = (); - } else if (type === 'searches') { + } else if (type === 'search') { calloutText = ( reference.name === savedObject.savedSearchRefName); + if (!reference) { + throw new Error(`Could not find reference "${savedObject.savedSearchRefName}"`); + } + savedObject.savedSearchId = reference.id; + delete savedObject.savedSearchRefName; +} diff --git a/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/saved_visualization_references.test.js b/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/saved_visualization_references.test.js new file mode 100644 index 00000000000000..be9375dc33e565 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/visualize/saved_visualizations/saved_visualization_references.test.js @@ -0,0 +1,117 @@ +/* + * 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 { extractReferences, injectReferences } from './saved_visualization_references'; + +describe('extractReferences', () => { + test('extracts nothing if savedSearchId is empty', () => { + const doc = { + id: '1', + attributes: { + foo: true, + }, + }; + const updatedDoc = extractReferences(doc); + expect(updatedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + }, + "references": Array [], +} +`); + }); + + test('extracts references from savedSearchId', () => { + const doc = { + id: '1', + attributes: { + foo: true, + savedSearchId: '123', + }, + }; + const updatedDoc = extractReferences(doc); + expect(updatedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + "savedSearchId": undefined, + "savedSearchRefName": "search_0", + }, + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], +} +`); + }); +}); + +describe('injectReferences', () => { + test('injects nothing when savedSearchRefName is null', () => { + const context = { + id: '1', + foo: true, + }; + injectReferences(context, []); + expect(context).toMatchInlineSnapshot(` +Object { + "foo": true, + "id": "1", +} +`); + }); + + test('injects references into context', () => { + const context = { + id: '1', + foo: true, + savedSearchRefName: 'search_0', + }; + const references = [ + { + name: 'search_0', + type: 'search', + id: '123', + }, + ]; + injectReferences(context, references); + expect(context).toMatchInlineSnapshot(` +Object { + "foo": true, + "id": "1", + "savedSearchId": "123", +} +`); + }); + + test(`fails when it can't find the reference in the array`, () => { + const context = { + id: '1', + foo: true, + savedSearchRefName: 'search_0', + }; + expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot( + `"Could not find reference \\"search_0\\""` + ); + }); +}); diff --git a/src/legacy/core_plugins/kibana/server/lib/__tests__/relationships.js b/src/legacy/core_plugins/kibana/server/lib/__tests__/relationships.js index 1c5473fe5e311e..42845b4cf0eb10 100644 --- a/src/legacy/core_plugins/kibana/server/lib/__tests__/relationships.js +++ b/src/legacy/core_plugins/kibana/server/lib/__tests__/relationships.js @@ -27,28 +27,44 @@ describe('findRelationships', () => { const size = 10; const savedObjectsClient = { - _index: '.kibana', get: () => ({ attributes: { - panelsJSON: JSON.stringify([{ id: '1' }, { id: '2' }, { id: '3' }]), + panelsJSON: JSON.stringify([{ panelRefName: 'panel_0' }, { panelRefName: 'panel_1' }, { panelRefName: 'panel_2' }]), }, + references: [{ + name: 'panel_0', + type: 'visualization', + id: '1', + }, { + name: 'panel_1', + type: 'visualization', + id: '2', + }, { + name: 'panel_2', + type: 'visualization', + id: '3', + }], }), - bulkGet: () => ({ + bulkGet: () => ({ saved_objects: [] }), + find: () => ({ saved_objects: [ { id: '1', + type: 'visualization', attributes: { title: 'Foo', }, }, { id: '2', + type: 'visualization', attributes: { title: 'Bar', }, }, { id: '3', + type: 'visualization', attributes: { title: 'FooBar', }, @@ -59,11 +75,14 @@ describe('findRelationships', () => { const result = await findRelationships( type, id, - size, - savedObjectsClient + { + size, + savedObjectsClient, + savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], + }, ); expect(result).to.eql({ - visualizations: [ + visualization: [ { id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }, { id: '3', title: 'FooBar' }, @@ -77,11 +96,36 @@ describe('findRelationships', () => { const size = 10; const savedObjectsClient = { - get: () => {}, + get: () => ({ + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }), + }, + }, + references: [{ + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: '1', + }], + }), + bulkGet: () => ({ + saved_objects: [ + { + id: '1', + type: 'index-pattern', + attributes: { + title: 'My Index Pattern', + }, + }, + ], + }), find: () => ({ saved_objects: [ { id: '1', + type: 'dashboard', attributes: { title: 'My Dashboard', panelsJSON: JSON.stringify([ @@ -98,6 +142,7 @@ describe('findRelationships', () => { }, { id: '2', + type: 'dashboard', attributes: { title: 'Your Dashboard', panelsJSON: JSON.stringify([ @@ -119,11 +164,17 @@ describe('findRelationships', () => { const result = await findRelationships( type, id, - size, - savedObjectsClient + { + size, + savedObjectsClient, + savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], + }, ); expect(result).to.eql({ - dashboards: [ + 'index-pattern': [ + { id: '1', title: 'My Index Pattern' }, + ], + dashboard: [ { id: '1', title: 'My Dashboard' }, { id: '2', title: 'Your Dashboard' }, ], @@ -136,43 +187,52 @@ describe('findRelationships', () => { const size = 10; const savedObjectsClient = { - get: type => { - if (type === 'search') { - return { + get: () => ({ + id: '1', + type: 'search', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }), + }, + }, + references: [{ + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: '1', + }], + }), + bulkGet: () => ({ + saved_objects: [ + { id: '1', + type: 'index-pattern', attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'index-pattern:1', - }), - }, + title: 'My Index Pattern', }, - }; - } - - return { - id: 'index-pattern:1', - attributes: { - title: 'My Index Pattern', }, - }; - }, + ], + }), find: () => ({ saved_objects: [ { id: '1', + type: 'visualization', attributes: { title: 'Foo', }, }, { id: '2', + type: 'visualization', attributes: { title: 'Bar', }, }, { id: '3', + type: 'visualization', attributes: { title: 'FooBar', }, @@ -184,16 +244,19 @@ describe('findRelationships', () => { const result = await findRelationships( type, id, - size, - savedObjectsClient + { + size, + savedObjectsClient, + savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], + }, ); expect(result).to.eql({ - visualizations: [ + visualization: [ { id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }, { id: '3', title: 'FooBar' }, ], - indexPatterns: [{ id: 'index-pattern:1', title: 'My Index Pattern' }], + 'index-pattern': [{ id: '1', title: 'My Index Pattern' }], }); }); @@ -203,106 +266,103 @@ describe('findRelationships', () => { const size = 10; const savedObjectsClient = { - get: () => {}, - find: options => { - if (options.type === 'visualization') { - return { - saved_objects: [ - { - id: '1', - found: true, - attributes: { - title: 'Foo', - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'foo', - }), - }, - }, - }, - { - id: '2', - found: true, - attributes: { - title: 'Bar', - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'foo', - }), - }, - }, - }, - { - id: '3', - found: true, - attributes: { - title: 'FooBar', - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'foo2', - }), - }, - }, - }, - ] - }; - } - - return { - saved_objects: [ - { - id: '1', - attributes: { - title: 'Foo', - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'foo', - }), - }, + get: () => ({ + id: '1', + type: 'index-pattern', + attributes: { + title: 'My Index Pattern' + }, + }), + find: () => ({ + saved_objects: [ + { + id: '1', + type: 'visualization', + attributes: { + title: 'Foo', + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + index: 'foo', + }), }, }, - { - id: '2', - attributes: { - title: 'Bar', - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'foo', - }), - }, + }, + { + id: '2', + type: 'visualization', + attributes: { + title: 'Bar', + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + index: 'foo', + }), }, }, - { - id: '3', - attributes: { - title: 'FooBar', - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - index: 'foo2', - }), - }, + }, + { + id: '3', + type: 'visualization', + attributes: { + title: 'FooBar', + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + index: 'foo2', + }), }, }, - ] - }; - } + }, + { + id: '1', + type: 'search', + attributes: { + title: 'My Saved Search', + }, + }, + ], + }), }; const result = await findRelationships( type, id, - size, - savedObjectsClient + { + size, + savedObjectsClient, + savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], + }, ); expect(result).to.eql({ - visualizations: [{ id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }], - searches: [{ id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }], + visualization: [{ id: '1', title: 'Foo' }, { id: '2', title: 'Bar' }, { id: '3', title: 'FooBar' }], + search: [{ id: '1', title: 'My Saved Search' }], }); }); - it('should return an empty object for invalid types', async () => { + it('should return an empty object for non related objects', async () => { const type = 'invalid'; - const result = await findRelationships(type); + const id = 'foo'; + const size = 10; + + const savedObjectsClient = { + get: () => ({ + id: '1', + type: 'index-pattern', + attributes: { + title: 'My Index Pattern', + }, + references: [], + }), + find: () => ({ saved_objects: [] }), + }; + + const result = await findRelationships( + type, + id, + { + size, + savedObjectsClient, + savedObjectTypes: ['dashboard', 'visualization', 'search', 'index-pattern'], + }, + ); expect(result).to.eql({}); }); }); diff --git a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_dashboards.js b/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_dashboards.js deleted file mode 100644 index fbbe0cc6e37014..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_dashboards.js +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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 sinon from 'sinon'; -import * as deps from '../collect_panels'; -import { collectDashboards } from '../collect_dashboards'; -import { expect } from 'chai'; - -describe('collectDashboards(req, ids)', () => { - - let collectPanelsStub; - const savedObjectsClient = { bulkGet: sinon.stub() }; - - const ids = ['dashboard-01', 'dashboard-02']; - - beforeEach(() => { - collectPanelsStub = sinon.stub(deps, 'collectPanels'); - collectPanelsStub.onFirstCall().returns(Promise.resolve([ - { id: 'dashboard-01' }, - { id: 'panel-01' }, - { id: 'index-*' } - ])); - collectPanelsStub.onSecondCall().returns(Promise.resolve([ - { id: 'dashboard-02' }, - { id: 'panel-01' }, - { id: 'index-*' } - ])); - - savedObjectsClient.bulkGet.returns(Promise.resolve({ - saved_objects: [ - { id: 'dashboard-01' }, { id: 'dashboard-02' } - ] - })); - }); - - afterEach(() => { - collectPanelsStub.restore(); - savedObjectsClient.bulkGet.resetHistory(); - }); - - it('should request all dashboards', async () => { - await collectDashboards(savedObjectsClient, ids); - - expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true); - - const args = savedObjectsClient.bulkGet.getCall(0).args; - expect(args[0]).to.eql([{ - id: 'dashboard-01', - type: 'dashboard' - }, { - id: 'dashboard-02', - type: 'dashboard' - }]); - }); - - it('should call collectPanels with dashboard docs', async () => { - await collectDashboards(savedObjectsClient, ids); - - expect(collectPanelsStub.calledTwice).to.equal(true); - expect(collectPanelsStub.args[0][1]).to.eql({ id: 'dashboard-01' }); - expect(collectPanelsStub.args[1][1]).to.eql({ id: 'dashboard-02' }); - }); - - it('should return an unique list of objects', async () => { - const results = await collectDashboards(savedObjectsClient, ids); - expect(results).to.eql([ - { id: 'dashboard-01' }, - { id: 'panel-01' }, - { id: 'index-*' }, - { id: 'dashboard-02' }, - ]); - }); -}); diff --git a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_index_patterns.js b/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_index_patterns.js deleted file mode 100644 index edbb8ecbf10b3c..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_index_patterns.js +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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 sinon from 'sinon'; -import { collectIndexPatterns } from '../collect_index_patterns'; -import { expect } from 'chai'; - -describe('collectIndexPatterns(req, panels)', () => { - const panels = [ - { - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ index: 'index-*' }) - } - } - }, { - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ index: 'logstash-*' }) - } - } - }, { - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ index: 'logstash-*' }) - } - } - }, { - attributes: { - savedSearchId: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ index: 'bad-*' }) - } - } - } - ]; - - const savedObjectsClient = { bulkGet: sinon.stub() }; - - beforeEach(() => { - savedObjectsClient.bulkGet.returns(Promise.resolve({ - saved_objects: [ - { id: 'index-*' }, { id: 'logstash-*' } - ] - })); - }); - - afterEach(() => { - savedObjectsClient.bulkGet.resetHistory(); - }); - - it('should request all index patterns', async () => { - await collectIndexPatterns(savedObjectsClient, panels); - - expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true); - expect(savedObjectsClient.bulkGet.getCall(0).args[0]).to.eql([{ - id: 'index-*', - type: 'index-pattern' - }, { - id: 'logstash-*', - type: 'index-pattern' - }]); - }); - - it('should return the index pattern docs', async () => { - const results = await collectIndexPatterns(savedObjectsClient, panels); - - expect(results).to.eql([ - { id: 'index-*' }, - { id: 'logstash-*' } - ]); - }); - - it('should return an empty array if nothing is requested', async () => { - const input = [ - { - attributes: { - savedSearchId: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ index: 'bad-*' }) - } - } - } - ]; - - const results = await collectIndexPatterns(savedObjectsClient, input); - expect(results).to.eql([]); - expect(savedObjectsClient.bulkGet.calledOnce).to.eql(false); - }); -}); diff --git a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_panels.js b/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_panels.js deleted file mode 100644 index f15d6a088f9977..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_panels.js +++ /dev/null @@ -1,105 +0,0 @@ -/* - * 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 sinon from 'sinon'; -import * as collectIndexPatternsDep from '../collect_index_patterns'; -import * as collectSearchSourcesDep from '../collect_search_sources'; -import { collectPanels } from '../collect_panels'; -import { expect } from 'chai'; - -describe('collectPanels(req, dashboard)', () => { - let collectSearchSourcesStub; - let collectIndexPatternsStub; - let dashboard; - - const savedObjectsClient = { bulkGet: sinon.stub() }; - - beforeEach(() => { - dashboard = { - attributes: { - panelsJSON: JSON.stringify([ - { id: 'panel-01', type: 'search' }, - { id: 'panel-02', type: 'visualization' } - ]) - } - }; - - savedObjectsClient.bulkGet.returns(Promise.resolve({ - saved_objects: [ - { id: 'panel-01' }, { id: 'panel-02' } - ] - })); - - collectIndexPatternsStub = sinon.stub(collectIndexPatternsDep, 'collectIndexPatterns'); - collectIndexPatternsStub.returns([{ id: 'logstash-*' }]); - collectSearchSourcesStub = sinon.stub(collectSearchSourcesDep, 'collectSearchSources'); - collectSearchSourcesStub.returns([ { id: 'search-01' }]); - }); - - afterEach(() => { - collectSearchSourcesStub.restore(); - collectIndexPatternsStub.restore(); - savedObjectsClient.bulkGet.resetHistory(); - }); - - it('should request each panel in the panelJSON', async () => { - await collectPanels(savedObjectsClient, dashboard); - - expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true); - expect(savedObjectsClient.bulkGet.getCall(0).args[0]).to.eql([{ - id: 'panel-01', - type: 'search' - }, { - id: 'panel-02', - type: 'visualization' - }]); - }); - - it('should call collectSearchSources()', async () => { - await collectPanels(savedObjectsClient, dashboard); - expect(collectSearchSourcesStub.calledOnce).to.equal(true); - expect(collectSearchSourcesStub.args[0][1]).to.eql([ - { id: 'panel-01' }, - { id: 'panel-02' } - ]); - }); - - it('should call collectIndexPatterns()', async () => { - await collectPanels(savedObjectsClient, dashboard); - - expect(collectIndexPatternsStub.calledOnce).to.equal(true); - expect(collectIndexPatternsStub.args[0][1]).to.eql([ - { id: 'panel-01' }, - { id: 'panel-02' } - ]); - }); - - it('should return panels, index patterns, search sources, and dashboard', async () => { - const results = await collectPanels(savedObjectsClient, dashboard); - - expect(results).to.eql([ - { id: 'panel-01' }, - { id: 'panel-02' }, - { id: 'logstash-*' }, - { id: 'search-01' }, - dashboard - ]); - }); - -}); diff --git a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_search_sources.js b/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_search_sources.js deleted file mode 100644 index 4f9de8a971b7c2..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/collect_search_sources.js +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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 sinon from 'sinon'; -import * as deps from '../collect_index_patterns'; -import { collectSearchSources } from '../collect_search_sources'; -import { expect } from 'chai'; -describe('collectSearchSources(req, panels)', () => { - const savedObjectsClient = { bulkGet: sinon.stub() }; - - let panels; - let collectIndexPatternsStub; - - beforeEach(() => { - panels = [ - { attributes: { savedSearchId: 1 } }, - { attributes: { savedSearchId: 2 } } - ]; - - collectIndexPatternsStub = sinon.stub(deps, 'collectIndexPatterns'); - collectIndexPatternsStub.returns(Promise.resolve([{ id: 'logstash-*' }])); - - savedObjectsClient.bulkGet.returns(Promise.resolve({ - saved_objects: [ - { id: 1 }, { id: 2 } - ] - })); - }); - - afterEach(() => { - collectIndexPatternsStub.restore(); - savedObjectsClient.bulkGet.resetHistory(); - }); - - it('should request all search sources', async () => { - await collectSearchSources(savedObjectsClient, panels); - - expect(savedObjectsClient.bulkGet.calledOnce).to.equal(true); - expect(savedObjectsClient.bulkGet.getCall(0).args[0]).to.eql([ - { type: 'search', id: 1 }, { type: 'search', id: 2 } - ]); - }); - - it('should return the search source and index patterns', async () => { - const results = await collectSearchSources(savedObjectsClient, panels); - - expect(results).to.eql([ - { id: 1 }, - { id: 2 }, - { id: 'logstash-*' } - ]); - }); - - it('should return an empty array if nothing is requested', async () => { - const input = [ - { - attributes: { - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ index: 'bad-*' }) - } - } - } - ]; - - const results = await collectSearchSources(savedObjectsClient, input); - expect(results).to.eql([]); - expect(savedObjectsClient.bulkGet.calledOnce).to.eql(false); - }); -}); diff --git a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/export_dashboards.js b/src/legacy/core_plugins/kibana/server/lib/export/__tests__/export_dashboards.js deleted file mode 100644 index 1fb93ec4d95c55..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/export/__tests__/export_dashboards.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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 * as deps from '../collect_dashboards'; -import { exportDashboards } from '../export_dashboards'; -import sinon from 'sinon'; -import { expect } from 'chai'; - -describe('exportDashboards(req)', () => { - - let req; - let collectDashboardsStub; - - beforeEach(() => { - req = { - query: { dashboard: 'dashboard-01' }, - server: { - config: () => ({ get: () => '6.0.0' }), - plugins: { - elasticsearch: { - getCluster: () => ({ callWithRequest: sinon.stub() }) - } - }, - }, - getSavedObjectsClient() { - return null; - } - }; - - collectDashboardsStub = sinon.stub(deps, 'collectDashboards'); - collectDashboardsStub.returns(Promise.resolve([ - { id: 'dashboard-01' }, - { id: 'logstash-*' }, - { id: 'panel-01' } - ])); - }); - - afterEach(() => { - collectDashboardsStub.restore(); - }); - - it('should return a response object with version', () => { - return exportDashboards(req).then((resp) => { - expect(resp).to.have.property('version', '6.0.0'); - }); - }); - - it('should return a response object with objects', () => { - return exportDashboards(req).then((resp) => { - expect(resp).to.have.property('objects'); - expect(resp.objects).to.eql([ - { id: 'dashboard-01' }, - { id: 'logstash-*' }, - { id: 'panel-01' } - ]); - }); - }); -}); diff --git a/src/legacy/core_plugins/kibana/server/lib/export/collect_dashboards.js b/src/legacy/core_plugins/kibana/server/lib/export/collect_dashboards.js deleted file mode 100644 index 969c2add1364d3..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/export/collect_dashboards.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 { collectPanels } from './collect_panels'; - -export async function collectDashboards(savedObjectsClient, ids) { - - if (ids.length === 0) return []; - - const objects = ids.map(id => { - return { - type: 'dashboard', - id: id - }; - }); - - const { saved_objects: savedObjects } = await savedObjectsClient.bulkGet(objects); - const results = await Promise.all(savedObjects.map(d => collectPanels(savedObjectsClient, d))); - - return results - .reduce((acc, result) => acc.concat(result), []) - .reduce((acc, obj) => { - if (!acc.find(o => o.id === obj.id)) acc.push(obj); - return acc; - }, []); - -} diff --git a/src/legacy/core_plugins/kibana/server/lib/export/collect_index_patterns.js b/src/legacy/core_plugins/kibana/server/lib/export/collect_index_patterns.js deleted file mode 100644 index 441871208dc082..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/export/collect_index_patterns.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 async function collectIndexPatterns(savedObjectsClient, panels) { - const docs = panels.reduce((acc, panel) => { - const { kibanaSavedObjectMeta, savedSearchId } = panel.attributes; - - if (kibanaSavedObjectMeta && kibanaSavedObjectMeta.searchSourceJSON && !savedSearchId) { - let searchSourceData; - try { - searchSourceData = JSON.parse(kibanaSavedObjectMeta.searchSourceJSON); - } catch (err) { - return acc; - } - - if (searchSourceData.index && !acc.find(s => s.id === searchSourceData.index)) { - acc.push({ type: 'index-pattern', id: searchSourceData.index }); - } - } - return acc; - }, []); - - if (docs.length === 0) return []; - - const { saved_objects: savedObjects } = await savedObjectsClient.bulkGet(docs); - return savedObjects; -} diff --git a/src/legacy/core_plugins/kibana/server/lib/export/collect_panels.js b/src/legacy/core_plugins/kibana/server/lib/export/collect_panels.js deleted file mode 100644 index eba6e335818e30..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/export/collect_panels.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 { get } from 'lodash'; - -import { collectIndexPatterns } from './collect_index_patterns'; -import { collectSearchSources } from './collect_search_sources'; - - -export async function collectPanels(savedObjectsClient, dashboard) { - let panels; - try { - panels = JSON.parse(get(dashboard, 'attributes.panelsJSON', '[]')); - } catch(err) { - panels = []; - } - - if (panels.length === 0) return [].concat([dashboard]); - - const { saved_objects: savedObjects } = await savedObjectsClient.bulkGet(panels); - const [ indexPatterns, searchSources ] = await Promise.all([ - collectIndexPatterns(savedObjectsClient, savedObjects), - collectSearchSources(savedObjectsClient, savedObjects) - ]); - - return savedObjects.concat(indexPatterns).concat(searchSources).concat([dashboard]); -} diff --git a/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.test.ts b/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.test.ts new file mode 100644 index 00000000000000..f5b9b299fd766a --- /dev/null +++ b/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { SavedObject } from '../../../../../../server/saved_objects/service/saved_objects_client'; +import { collectReferencesDeep } from './collect_references_deep'; + +const data = [ + { + id: '1', + type: 'dashboard', + attributes: { + panelsJSON: JSON.stringify([{ panelRefName: 'panel_0' }, { panelRefName: 'panel_1' }]), + }, + references: [ + { + name: 'panel_0', + type: 'visualization', + id: '2', + }, + { + name: 'panel_1', + type: 'visualization', + id: '3', + }, + ], + }, + { + id: '2', + type: 'visualization', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }), + }, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: '4', + }, + ], + }, + { + id: '3', + type: 'visualization', + attributes: { + savedSearchRefName: 'search_0', + }, + references: [ + { + name: 'search_0', + type: 'search', + id: '5', + }, + ], + }, + { + id: '4', + type: 'index-pattern', + attributes: { + title: 'pattern*', + }, + }, + { + id: '5', + type: 'search', + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }), + }, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: '4', + }, + ], + }, +]; + +test('collects dashboard and all dependencies', async () => { + const savedObjectClient = { + errors: {} as any, + create: jest.fn(), + bulkCreate: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + bulkGet: jest.fn(getObjects => { + return { + saved_objects: getObjects.map((obj: SavedObject) => + data.find(row => row.id === obj.id && row.type === obj.type) + ), + }; + }), + }; + const objects = await collectReferencesDeep(savedObjectClient, [{ type: 'dashboard', id: '1' }]); + expect(objects).toMatchInlineSnapshot(` +Array [ + Object { + "attributes": Object { + "panelsJSON": "[{\\"panelRefName\\":\\"panel_0\\"},{\\"panelRefName\\":\\"panel_1\\"}]", + }, + "id": "1", + "references": Array [ + Object { + "id": "2", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "3", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", + }, + Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}", + }, + }, + "id": "2", + "references": Array [ + Object { + "id": "4", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern", + }, + ], + "type": "visualization", + }, + Object { + "attributes": Object { + "savedSearchRefName": "search_0", + }, + "id": "3", + "references": Array [ + Object { + "id": "5", + "name": "search_0", + "type": "search", + }, + ], + "type": "visualization", + }, + Object { + "attributes": Object { + "title": "pattern*", + }, + "id": "4", + "type": "index-pattern", + }, + Object { + "attributes": Object { + "kibanaSavedObjectMeta": Object { + "searchSourceJSON": "{\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}", + }, + }, + "id": "5", + "references": Array [ + Object { + "id": "4", + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern", + }, + ], + "type": "search", + }, +] +`); +}); diff --git a/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.ts b/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.ts new file mode 100644 index 00000000000000..f62fc3c474c085 --- /dev/null +++ b/src/legacy/core_plugins/kibana/server/lib/export/collect_references_deep.ts @@ -0,0 +1,55 @@ +/* + * 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 { + SavedObject, + SavedObjectsClient, +} from '../../../../../../server/saved_objects/service/saved_objects_client'; + +const MAX_BULK_GET_SIZE = 10000; + +interface ObjectsToCollect { + id: string; + type: string; +} + +export async function collectReferencesDeep( + savedObjectClient: SavedObjectsClient, + objects: ObjectsToCollect[] +) { + let result: SavedObject[] = []; + const queue = [...objects]; + while (queue.length !== 0) { + const itemsToGet = queue.splice(0, MAX_BULK_GET_SIZE); + const { saved_objects: savedObjects } = await savedObjectClient.bulkGet(itemsToGet); + result = result.concat(savedObjects); + for (const { references = [] } of savedObjects) { + for (const reference of references) { + const isDuplicate = queue + .concat(result) + .some(obj => obj.type === reference.type && obj.id === reference.id); + if (isDuplicate) { + continue; + } + queue.push({ type: reference.type, id: reference.id }); + } + } + } + return result; +} diff --git a/src/legacy/core_plugins/kibana/server/lib/export/collect_search_sources.js b/src/legacy/core_plugins/kibana/server/lib/export/collect_search_sources.js deleted file mode 100644 index 38471e9b6012c3..00000000000000 --- a/src/legacy/core_plugins/kibana/server/lib/export/collect_search_sources.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 { collectIndexPatterns } from './collect_index_patterns'; - -export async function collectSearchSources(savedObjectsClient, panels) { - const docs = panels.reduce((acc, panel) => { - const { savedSearchId } = panel.attributes; - if (savedSearchId) { - if (!acc.find(s => s.id === savedSearchId) && !panels.find(p => p.id === savedSearchId)) { - acc.push({ type: 'search', id: savedSearchId }); - } - } - return acc; - }, []); - - if (docs.length === 0) return []; - - const { saved_objects: savedObjects } = await savedObjectsClient.bulkGet(docs); - const indexPatterns = await collectIndexPatterns(savedObjectsClient, savedObjects); - - return savedObjects.concat(indexPatterns); -} diff --git a/src/legacy/core_plugins/kibana/server/lib/export/export_dashboards.js b/src/legacy/core_plugins/kibana/server/lib/export/export_dashboards.js index ed61d2c83366af..07b1bafd61c91b 100644 --- a/src/legacy/core_plugins/kibana/server/lib/export/export_dashboards.js +++ b/src/legacy/core_plugins/kibana/server/lib/export/export_dashboards.js @@ -18,7 +18,7 @@ */ import _ from 'lodash'; -import { collectDashboards } from './collect_dashboards'; +import { collectReferencesDeep } from './collect_references_deep'; export async function exportDashboards(req) { @@ -26,8 +26,9 @@ export async function exportDashboards(req) { const config = req.server.config(); const savedObjectsClient = req.getSavedObjectsClient(); + const objectsToExport = ids.map(id => ({ id, type: 'dashboard' })); - const objects = await collectDashboards(savedObjectsClient, ids); + const objects = await collectReferencesDeep(savedObjectsClient, objectsToExport); return { version: config.get('pkg.version'), objects diff --git a/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/relationships.js b/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/relationships.js index e98bebe4c40662..afed200476343c 100644 --- a/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/relationships.js +++ b/src/legacy/core_plugins/kibana/server/lib/management/saved_objects/relationships.js @@ -17,166 +17,45 @@ * under the License. */ -async function findDashboardRelationships(id, size, savedObjectsClient) { - const dashboard = await savedObjectsClient.get('dashboard', id); - const visualizations = []; - - // TODO: should we handle exceptions here or at the parent level? - const panelsJSON = JSON.parse(dashboard.attributes.panelsJSON); - if (panelsJSON) { - const visualizationIds = panelsJSON.map(panel => panel.id); - const visualizationResponse = await savedObjectsClient.bulkGet( - visualizationIds.slice(0, size).map(id => ({ - id, - type: 'visualization', - })) - ); - - visualizations.push( - ...visualizationResponse.saved_objects.reduce((accum, object) => { - if (!object.error) { - accum.push({ - id: object.id, - title: object.attributes.title, - }); - } - return accum; - }, []) - ); - } - - return { visualizations }; -} - -async function findVisualizationRelationships(id, size, savedObjectsClient) { - await savedObjectsClient.get('visualization', id); - const allDashboardsResponse = await savedObjectsClient.find({ - type: 'dashboard', - fields: ['title', 'panelsJSON'], - }); - - const dashboards = []; - for (const dashboard of allDashboardsResponse.saved_objects) { - if (dashboard.error) { - continue; - } - const panelsJSON = JSON.parse(dashboard.attributes.panelsJSON); - if (panelsJSON) { - for (const panel of panelsJSON) { - if (panel.type === 'visualization' && panel.id === id) { - dashboards.push({ - id: dashboard.id, - title: dashboard.attributes.title, - }); - } - } - } - - if (dashboards.length >= size) { - break; - } - } - return { dashboards }; -} - -async function findSavedSearchRelationships(id, size, savedObjectsClient) { - const search = await savedObjectsClient.get('search', id); - - const searchSourceJSON = JSON.parse(search.attributes.kibanaSavedObjectMeta.searchSourceJSON); - - const indexPatterns = []; - try { - const indexPattern = await savedObjectsClient.get('index-pattern', searchSourceJSON.index); - indexPatterns.push({ id: indexPattern.id, title: indexPattern.attributes.title }); - } catch (err) { - // Do nothing - } - - const allVisualizationsResponse = await savedObjectsClient.find({ - type: 'visualization', - searchFields: ['savedSearchId'], - search: id, - fields: ['title'], - }); - - const visualizations = allVisualizationsResponse.saved_objects.reduce((accum, object) => { - if (!object.error) { - accum.push({ - id: object.id, - title: object.attributes.title, - }); - } - return accum; - }, []); - - return { visualizations, indexPatterns }; -} - -async function findIndexPatternRelationships(id, size, savedObjectsClient) { - await savedObjectsClient.get('index-pattern', id); - const [allVisualizationsResponse, savedSearchResponse] = await Promise.all([ +export async function findRelationships(type, id, options = {}) { + const { + size, + savedObjectsClient, + savedObjectTypes, + } = options; + + const { references = [] } = await savedObjectsClient.get(type, id); + const bulkGetOpts = references.map(ref => ({ id: ref.id, type: ref.type })); + + const [referencedObjects, referencedResponse] = await Promise.all([ + bulkGetOpts.length > 0 + ? savedObjectsClient.bulkGet(bulkGetOpts) + : Promise.resolve({ saved_objects: [] }), savedObjectsClient.find({ - type: 'visualization', - searchFields: ['kibanaSavedObjectMeta.searchSourceJSON'], - search: '*', - fields: [`title`, `kibanaSavedObjectMeta.searchSourceJSON`], - }), - savedObjectsClient.find({ - type: 'search', - searchFields: ['kibanaSavedObjectMeta.searchSourceJSON'], - search: '*', - fields: [`title`, `kibanaSavedObjectMeta.searchSourceJSON`], + hasReference: { type, id }, + perPage: size, + fields: ['title'], + type: savedObjectTypes, }), ]); - const visualizations = []; - for (const visualization of allVisualizationsResponse.saved_objects) { - if (visualization.error) { - continue; - } - const searchSourceJSON = JSON.parse(visualization.attributes.kibanaSavedObjectMeta.searchSourceJSON); - if (searchSourceJSON && searchSourceJSON.index === id) { - visualizations.push({ - id: visualization.id, - title: visualization.attributes.title, - }); - } - - if (visualizations.length >= size) { - break; - } - } - - const searches = []; - for (const search of savedSearchResponse.saved_objects) { - if (search.error) { - continue; - } - const searchSourceJSON = JSON.parse(search.attributes.kibanaSavedObjectMeta.searchSourceJSON); - if (searchSourceJSON && searchSourceJSON.index === id) { - searches.push({ - id: search.id, - title: search.attributes.title, - }); - } - - if (searches.length >= size) { - break; - } - } - return { visualizations, searches }; + const relationshipObjects = [].concat( + referencedObjects.saved_objects.map(extractCommonProperties), + referencedResponse.saved_objects.map(extractCommonProperties), + ); + + return relationshipObjects.reduce((result, relationshipObject) => { + const objectsForType = (result[relationshipObject.type] || []); + const { type, ...relationshipObjectWithoutType } = relationshipObject; + result[type] = objectsForType.concat(relationshipObjectWithoutType); + return result; + }, {}); } -export async function findRelationships(type, id, size, savedObjectsClient) { - switch (type) { - case 'dashboard': - return await findDashboardRelationships(id, size, savedObjectsClient); - case 'visualization': - return await findVisualizationRelationships(id, size, savedObjectsClient); - case 'search': - return await findSavedSearchRelationships(id, size, savedObjectsClient); - case 'index-pattern': - return await findIndexPatternRelationships(id, size, savedObjectsClient); - } - return {}; +function extractCommonProperties(savedObject) { + return { + id: savedObject.id, + type: savedObject.type, + title: savedObject.attributes.title, + }; } diff --git a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js b/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js index d88cc05c97f8dc..268f16b4f4afa8 100644 --- a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js +++ b/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/relationships.js @@ -17,10 +17,8 @@ * under the License. */ -import Boom from 'boom'; import Joi from 'joi'; import { findRelationships } from '../../../../lib/management/saved_objects/relationships'; -import { isNotFoundError } from '../../../../../../../../server/saved_objects/service/lib/errors'; export function registerRelationships(server) { server.route({ @@ -33,7 +31,7 @@ export function registerRelationships(server) { id: Joi.string(), }), query: Joi.object().keys({ - size: Joi.number(), + size: Joi.number().default(10000), }), }, }, @@ -41,17 +39,15 @@ export function registerRelationships(server) { handler: async (req) => { const type = req.params.type; const id = req.params.id; - const size = req.query.size || 10; + const size = req.query.size; + const savedObjectsClient = req.getSavedObjectsClient(); - try { - return await findRelationships(type, id, size, req.getSavedObjectsClient()); - } catch (err) { - if (isNotFoundError(err)) { - throw Boom.boomify(new Error('Resource not found'), { statusCode: 404 }); - } - - throw Boom.boomify(err, { statusCode: 500 }); - } + return await findRelationships(type, id, { + size, + savedObjectsClient, + // Pass in all types except space, spaces wrapper will throw error + savedObjectTypes: server.savedObjects.types.filter(type => type !== 'space'), + }); }, }); } diff --git a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js b/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js index 76cc66c05385c4..a2cc63b4b8679b 100644 --- a/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js +++ b/src/legacy/core_plugins/kibana/server/routes/api/management/saved_objects/scroll.js @@ -62,6 +62,7 @@ export function registerScrollForExportRoute(server) { savedObjectVersion: 2 }, _migrationVersion: hit.migrationVersion, + _references: hit.references || [], }; }); } diff --git a/src/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index 156c5fa6887f41..7e6b8bf5e9e58f 100644 --- a/src/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -25,6 +25,20 @@ Object { "namespace": Object { "type": "keyword", }, + "references": Object { + "properties": Object { + "id": Object { + "type": "keyword", + }, + "name": Object { + "type": "keyword", + }, + "type": Object { + "type": "keyword", + }, + }, + "type": "nested", + }, "type": Object { "type": "keyword", }, diff --git a/src/server/saved_objects/migrations/core/build_active_mappings.ts b/src/server/saved_objects/migrations/core/build_active_mappings.ts index 156f47faecd561..eae75b418ed54f 100644 --- a/src/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/server/saved_objects/migrations/core/build_active_mappings.ts @@ -74,6 +74,20 @@ function defaultMapping(): IndexMapping { updated_at: { type: 'date', }, + references: { + type: 'nested', + properties: { + name: { + type: 'keyword', + }, + type: { + type: 'keyword', + }, + id: { + type: 'keyword', + }, + }, + }, }, }; } diff --git a/src/server/saved_objects/migrations/core/document_migrator.test.ts b/src/server/saved_objects/migrations/core/document_migrator.test.ts index cdd895594e4dd9..293d96c8e62f49 100644 --- a/src/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/server/saved_objects/migrations/core/document_migrator.test.ts @@ -19,7 +19,7 @@ import _ from 'lodash'; import sinon from 'sinon'; -import { SavedObjectDoc } from '../../serialization'; +import { RawSavedObjectDoc } from '../../serialization'; import { DocumentMigrator } from './document_migrator'; describe('DocumentMigrator', () => { @@ -486,13 +486,13 @@ describe('DocumentMigrator', () => { ...testOpts(), migrations: { aaa: { - '1.2.3': (doc: SavedObjectDoc) => doc, - '10.4.0': (doc: SavedObjectDoc) => doc, - '2.2.1': (doc: SavedObjectDoc) => doc, + '1.2.3': (doc: RawSavedObjectDoc) => doc, + '10.4.0': (doc: RawSavedObjectDoc) => doc, + '2.2.1': (doc: RawSavedObjectDoc) => doc, }, bbb: { - '3.2.3': (doc: SavedObjectDoc) => doc, - '2.0.0': (doc: SavedObjectDoc) => doc, + '3.2.3': (doc: RawSavedObjectDoc) => doc, + '2.0.0': (doc: RawSavedObjectDoc) => doc, }, }, }); @@ -525,11 +525,11 @@ describe('DocumentMigrator', () => { }); function renameAttr(path: string, newPath: string) { - return (doc: SavedObjectDoc) => - _.omit(_.set(doc, newPath, _.get(doc, path)), path) as SavedObjectDoc; + return (doc: RawSavedObjectDoc) => + _.omit(_.set(doc, newPath, _.get(doc, path)), path) as RawSavedObjectDoc; } function setAttr(path: string, value: any) { - return (doc: SavedObjectDoc) => - _.set(doc, path, _.isFunction(value) ? value(_.get(doc, path)) : value) as SavedObjectDoc; + return (doc: RawSavedObjectDoc) => + _.set(doc, path, _.isFunction(value) ? value(_.get(doc, path)) : value) as RawSavedObjectDoc; } diff --git a/src/server/saved_objects/migrations/core/document_migrator.ts b/src/server/saved_objects/migrations/core/document_migrator.ts index 0e0631aa8fce22..4a2a5aedc250d7 100644 --- a/src/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/server/saved_objects/migrations/core/document_migrator.ts @@ -63,12 +63,12 @@ import Boom from 'boom'; import _ from 'lodash'; import Semver from 'semver'; -import { MigrationVersion, SavedObjectDoc } from '../../serialization'; +import { MigrationVersion, RawSavedObjectDoc } from '../../serialization'; import { LogFn, Logger, MigrationLogger } from './migration_logger'; -export type TransformFn = (doc: SavedObjectDoc) => SavedObjectDoc; +export type TransformFn = (doc: RawSavedObjectDoc) => RawSavedObjectDoc; -type ValidateDoc = (doc: SavedObjectDoc) => void; +type ValidateDoc = (doc: RawSavedObjectDoc) => void; interface MigrationDefinition { [type: string]: { [version: string]: TransformFn }; @@ -142,11 +142,11 @@ export class DocumentMigrator implements VersionedTransformer { /** * Migrates a document to the latest version. * - * @param {SavedObjectDoc} doc - * @returns {SavedObjectDoc} + * @param {RawSavedObjectDoc} doc + * @returns {RawSavedObjectDoc} * @memberof DocumentMigrator */ - public migrate = (doc: SavedObjectDoc): SavedObjectDoc => { + public migrate = (doc: RawSavedObjectDoc): RawSavedObjectDoc => { return this.transformDoc(doc); }; } @@ -225,7 +225,7 @@ function buildDocumentTransform({ migrations: ActiveMigrations; validateDoc: ValidateDoc; }): TransformFn { - return function transformAndValidate(doc: SavedObjectDoc) { + return function transformAndValidate(doc: RawSavedObjectDoc) { const result = doc.migrationVersion ? applyMigrations(doc, migrations) : markAsUpToDate(doc, migrations); @@ -243,7 +243,7 @@ function buildDocumentTransform({ }; } -function applyMigrations(doc: SavedObjectDoc, migrations: ActiveMigrations) { +function applyMigrations(doc: RawSavedObjectDoc, migrations: ActiveMigrations) { while (true) { const prop = nextUnmigratedProp(doc, migrations); if (!prop) { @@ -256,14 +256,14 @@ function applyMigrations(doc: SavedObjectDoc, migrations: ActiveMigrations) { /** * Gets the doc's props, handling the special case of "type". */ -function props(doc: SavedObjectDoc) { +function props(doc: RawSavedObjectDoc) { return Object.keys(doc).concat(doc.type); } /** * Looks up the prop version in a saved object document or in our latest migrations. */ -function propVersion(doc: SavedObjectDoc | ActiveMigrations, prop: string) { +function propVersion(doc: RawSavedObjectDoc | ActiveMigrations, prop: string) { return ( (doc[prop] && doc[prop].latestVersion) || (doc.migrationVersion && (doc as any).migrationVersion[prop]) @@ -273,7 +273,7 @@ function propVersion(doc: SavedObjectDoc | ActiveMigrations, prop: string) { /** * Sets the doc's migrationVersion to be the most recent version */ -function markAsUpToDate(doc: SavedObjectDoc, migrations: ActiveMigrations) { +function markAsUpToDate(doc: RawSavedObjectDoc, migrations: ActiveMigrations) { return { ...doc, migrationVersion: props(doc).reduce((acc, prop) => { @@ -288,7 +288,7 @@ function markAsUpToDate(doc: SavedObjectDoc, migrations: ActiveMigrations) { * about the document and transform that caused the failure. */ function wrapWithTry(version: string, prop: string, transform: TransformFn, log: Logger) { - return function tryTransformDoc(doc: SavedObjectDoc) { + return function tryTransformDoc(doc: RawSavedObjectDoc) { try { const result = transform(doc); @@ -313,7 +313,7 @@ function wrapWithTry(version: string, prop: string, transform: TransformFn, log: /** * Finds the first unmigrated property in the specified document. */ -function nextUnmigratedProp(doc: SavedObjectDoc, migrations: ActiveMigrations) { +function nextUnmigratedProp(doc: RawSavedObjectDoc, migrations: ActiveMigrations) { return props(doc).find(p => { const latestVersion = propVersion(migrations, p); const docVersion = propVersion(doc, p); @@ -343,10 +343,10 @@ function nextUnmigratedProp(doc: SavedObjectDoc, migrations: ActiveMigrations) { * Applies any relevent migrations to the document for the specified property. */ function migrateProp( - doc: SavedObjectDoc, + doc: RawSavedObjectDoc, prop: string, migrations: ActiveMigrations -): SavedObjectDoc { +): RawSavedObjectDoc { const originalType = doc.type; let migrationVersion = _.clone(doc.migrationVersion) || {}; const typeChanged = () => !doc.hasOwnProperty(prop) || doc.type !== originalType; @@ -367,7 +367,7 @@ function migrateProp( /** * Retrieves any prop transforms that have not been applied to doc. */ -function applicableTransforms(migrations: ActiveMigrations, doc: SavedObjectDoc, prop: string) { +function applicableTransforms(migrations: ActiveMigrations, doc: RawSavedObjectDoc, prop: string) { const minVersion = propVersion(doc, prop); const { transforms } = migrations[prop]; return minVersion @@ -380,7 +380,7 @@ function applicableTransforms(migrations: ActiveMigrations, doc: SavedObjectDoc, * has not mutated migrationVersion in an unsupported way. */ function updateMigrationVersion( - doc: SavedObjectDoc, + doc: RawSavedObjectDoc, migrationVersion: MigrationVersion, prop: string, version: string @@ -396,7 +396,7 @@ function updateMigrationVersion( * as this could get us into an infinite loop. So, we explicitly check for that here. */ function assertNoDowngrades( - doc: SavedObjectDoc, + doc: RawSavedObjectDoc, migrationVersion: MigrationVersion, prop: string, version: string diff --git a/src/server/saved_objects/migrations/core/index_migrator.test.ts b/src/server/saved_objects/migrations/core/index_migrator.test.ts index a5be8e52167731..68e13bfbb53e23 100644 --- a/src/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/server/saved_objects/migrations/core/index_migrator.test.ts @@ -20,7 +20,7 @@ import _ from 'lodash'; import sinon from 'sinon'; import { SavedObjectsSchema } from '../../schema'; -import { SavedObjectDoc, SavedObjectsSerializer } from '../../serialization'; +import { RawSavedObjectDoc, SavedObjectsSerializer } from '../../serialization'; import { CallCluster } from './call_cluster'; import { IndexMigrator } from './index_migrator'; @@ -50,6 +50,14 @@ describe('IndexMigrator', () => { namespace: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + references: { + type: 'nested', + properties: { + name: { type: 'keyword' }, + type: { type: 'keyword' }, + id: { type: 'keyword' }, + }, + }, }, }, include_type_name: true, @@ -83,6 +91,14 @@ describe('IndexMigrator', () => { namespace: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + references: { + type: 'nested', + properties: { + name: { type: 'keyword' }, + type: { type: 'keyword' }, + id: { type: 'keyword' }, + }, + }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, @@ -191,6 +207,14 @@ describe('IndexMigrator', () => { namespace: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + references: { + type: 'nested', + properties: { + name: { type: 'keyword' }, + type: { type: 'keyword' }, + id: { type: 'keyword' }, + }, + }, }, }, settings: { number_of_shards: 1, auto_expand_replicas: '0-1' }, @@ -240,7 +264,7 @@ describe('IndexMigrator', () => { let count = 0; const opts = defaultOpts(); const callCluster = clusterStub(opts); - const migrateDoc = sinon.spy((doc: SavedObjectDoc) => ({ + const migrateDoc = sinon.spy((doc: RawSavedObjectDoc) => ({ ...doc, attributes: { name: ++count }, })); @@ -266,24 +290,26 @@ describe('IndexMigrator', () => { type: 'foo', attributes: { name: 'Bar' }, migrationVersion: {}, + references: [], }); sinon.assert.calledWith(migrateDoc, { id: '2', type: 'foo', attributes: { name: 'Baz' }, migrationVersion: {}, + references: [], }); expect(callCluster.args.filter(([action]) => action === 'bulk').length).toEqual(2); sinon.assert.calledWith(callCluster, 'bulk', { body: [ { index: { _id: 'foo:1', _index: '.kibana_2' } }, - { foo: { name: 1 }, type: 'foo', migrationVersion: {} }, + { foo: { name: 1 }, type: 'foo', migrationVersion: {}, references: [] }, ], }); sinon.assert.calledWith(callCluster, 'bulk', { body: [ { index: { _id: 'foo:2', _index: '.kibana_2' } }, - { foo: { name: 2 }, type: 'foo', migrationVersion: {} }, + { foo: { name: 2 }, type: 'foo', migrationVersion: {}, references: [] }, ], }); }); diff --git a/src/server/saved_objects/migrations/core/migrate_raw_docs.test.ts b/src/server/saved_objects/migrations/core/migrate_raw_docs.test.ts index 01bd409da8b699..26ccb4085e2a96 100644 --- a/src/server/saved_objects/migrations/core/migrate_raw_docs.test.ts +++ b/src/server/saved_objects/migrations/core/migrate_raw_docs.test.ts @@ -34,11 +34,11 @@ describe('migrateRawDocs', () => { expect(result).toEqual([ { _id: 'a:b', - _source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {} }, + _source: { type: 'a', a: { name: 'HOI!' }, migrationVersion: {}, references: [] }, }, { _id: 'c:d', - _source: { type: 'c', c: { name: 'HOI!' }, migrationVersion: {} }, + _source: { type: 'c', c: { name: 'HOI!' }, migrationVersion: {}, references: [] }, }, ]); @@ -56,7 +56,7 @@ describe('migrateRawDocs', () => { { _id: 'foo:b', _source: { type: 'a', a: { name: 'AAA' } } }, { _id: 'c:d', - _source: { type: 'c', c: { name: 'TADA' }, migrationVersion: {} }, + _source: { type: 'c', c: { name: 'TADA' }, migrationVersion: {}, references: [] }, }, ]); @@ -69,6 +69,7 @@ describe('migrateRawDocs', () => { name: 'DDD', }, migrationVersion: {}, + references: [], }, ], ]); diff --git a/src/server/saved_objects/migrations/core/migrate_raw_docs.ts b/src/server/saved_objects/migrations/core/migrate_raw_docs.ts index ffafa9908d23e1..c008b18619629c 100644 --- a/src/server/saved_objects/migrations/core/migrate_raw_docs.ts +++ b/src/server/saved_objects/migrations/core/migrate_raw_docs.ts @@ -41,7 +41,10 @@ export function migrateRawDocs( if (serializer.isRawSavedObject(raw)) { const savedObject = serializer.rawToSavedObject(raw); savedObject.migrationVersion = savedObject.migrationVersion || {}; - return serializer.savedObjectToRaw(migrateDoc(savedObject)); + return serializer.savedObjectToRaw({ + references: [], + ...migrateDoc(savedObject), + }); } return raw; diff --git a/src/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 1733f94ded5521..9627a351f55d4d 100644 --- a/src/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -25,6 +25,20 @@ Object { "namespace": Object { "type": "keyword", }, + "references": Object { + "properties": Object { + "id": Object { + "type": "keyword", + }, + "name": Object { + "type": "keyword", + }, + "type": Object { + "type": "keyword", + }, + }, + "type": "nested", + }, "type": Object { "type": "keyword", }, diff --git a/src/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/server/saved_objects/migrations/kibana/kibana_migrator.ts index 06ff383133d358..3eddd46586d6b4 100644 --- a/src/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -24,7 +24,7 @@ import { once } from 'lodash'; import { SavedObjectsSchema, SavedObjectsSchemaDefinition } from '../../schema'; -import { SavedObjectDoc, SavedObjectsSerializer } from '../../serialization'; +import { RawSavedObjectDoc, SavedObjectsSerializer } from '../../serialization'; import { docValidator } from '../../validation'; import { buildActiveMappings, CallCluster, IndexMigrator, LogFn, MappingProperties } from '../core'; import { DocumentMigrator, VersionedTransformer } from '../core/document_migrator'; @@ -146,11 +146,11 @@ export class KibanaMigrator { /** * Migrates an individual doc to the latest version, as defined by the plugin migrations. * - * @param {SavedObjectDoc} doc - * @returns {SavedObjectDoc} + * @param {RawSavedObjectDoc} doc + * @returns {RawSavedObjectDoc} * @memberof KibanaMigrator */ - public migrateDocument(doc: SavedObjectDoc): SavedObjectDoc { + public migrateDocument(doc: RawSavedObjectDoc): RawSavedObjectDoc { return this.documentMigrator.migrate(doc); } } diff --git a/src/server/saved_objects/routes/bulk_create.js b/src/server/saved_objects/routes/bulk_create.js index 943b2520a331d7..d2dc14e3f6f7bd 100644 --- a/src/server/saved_objects/routes/bulk_create.js +++ b/src/server/saved_objects/routes/bulk_create.js @@ -37,6 +37,14 @@ export const createBulkCreateRoute = prereqs => ({ attributes: Joi.object().required(), version: Joi.number(), migrationVersion: Joi.object().optional(), + references: Joi.array().items( + Joi.object() + .keys({ + name: Joi.string().required(), + type: Joi.string().required(), + id: Joi.string().required(), + }), + ).default([]), }).required() ), }, diff --git a/src/server/saved_objects/routes/bulk_get.test.js b/src/server/saved_objects/routes/bulk_get.test.js index e646629970fd43..62c155a57a165e 100644 --- a/src/server/saved_objects/routes/bulk_get.test.js +++ b/src/server/saved_objects/routes/bulk_get.test.js @@ -59,7 +59,8 @@ describe('POST /api/saved_objects/_bulk_get', () => { id: 'abc123', type: 'index-pattern', title: 'logstash-*', - version: 2 + version: 2, + references: [], }] }; diff --git a/src/server/saved_objects/routes/create.js b/src/server/saved_objects/routes/create.js index 46d47d0f338910..a93e23a57550bf 100644 --- a/src/server/saved_objects/routes/create.js +++ b/src/server/saved_objects/routes/create.js @@ -40,14 +40,22 @@ export const createCreateRoute = prereqs => { payload: Joi.object({ attributes: Joi.object().required(), migrationVersion: Joi.object().optional(), + references: Joi.array().items( + Joi.object() + .keys({ + name: Joi.string().required(), + type: Joi.string().required(), + id: Joi.string().required(), + }), + ).default([]), }).required(), }, handler(request) { const { savedObjectsClient } = request.pre; const { type, id } = request.params; const { overwrite } = request.query; - const { migrationVersion } = request.payload; - const options = { id, overwrite, migrationVersion }; + const { migrationVersion, references } = request.payload; + const options = { id, overwrite, migrationVersion, references }; return savedObjectsClient.create(type, request.payload.attributes, options); }, diff --git a/src/server/saved_objects/routes/create.test.js b/src/server/saved_objects/routes/create.test.js index 48b6cb970e56b5..4e35e2e3b38d4e 100644 --- a/src/server/saved_objects/routes/create.test.js +++ b/src/server/saved_objects/routes/create.test.js @@ -57,7 +57,8 @@ describe('POST /api/saved_objects/{type}', () => { const clientResponse = { type: 'index-pattern', id: 'logstash-*', - title: 'Testing' + title: 'Testing', + references: [], }; savedObjectsClient.create.returns(Promise.resolve(clientResponse)); @@ -100,7 +101,7 @@ describe('POST /api/saved_objects/{type}', () => { expect(savedObjectsClient.create.calledOnce).toBe(true); const args = savedObjectsClient.create.getCall(0).args; - const options = { overwrite: false, id: undefined, migrationVersion: undefined }; + const options = { overwrite: false, id: undefined, migrationVersion: undefined, references: [] }; const attributes = { title: 'Testing' }; expect(args).toEqual(['index-pattern', attributes, options]); @@ -121,7 +122,7 @@ describe('POST /api/saved_objects/{type}', () => { expect(savedObjectsClient.create.calledOnce).toBe(true); const args = savedObjectsClient.create.getCall(0).args; - const options = { overwrite: false, id: 'logstash-*' }; + const options = { overwrite: false, id: 'logstash-*', references: [] }; const attributes = { title: 'Testing' }; expect(args).toEqual(['index-pattern', attributes, options]); diff --git a/src/server/saved_objects/routes/find.js b/src/server/saved_objects/routes/find.js index 27a30f0aa64274..2dd92c01e98678 100644 --- a/src/server/saved_objects/routes/find.js +++ b/src/server/saved_objects/routes/find.js @@ -34,6 +34,11 @@ export const createFindRoute = (prereqs) => ({ default_search_operator: Joi.string().valid('OR', 'AND').default('OR'), search_fields: Joi.array().items(Joi.string()).single(), sort_field: Joi.array().items(Joi.string()).single(), + has_reference: Joi.object() + .keys({ + type: Joi.string().required(), + id: Joi.string().required(), + }).optional(), fields: Joi.array().items(Joi.string()).single() }).default() }, diff --git a/src/server/saved_objects/routes/find.test.js b/src/server/saved_objects/routes/find.test.js index 5c865c22f426be..d11ac8f00cb544 100644 --- a/src/server/saved_objects/routes/find.test.js +++ b/src/server/saved_objects/routes/find.test.js @@ -74,13 +74,15 @@ describe('GET /api/saved_objects/_find', () => { id: 'logstash-*', title: 'logstash-*', timeFieldName: '@timestamp', - notExpandable: true + notExpandable: true, + references: [], }, { type: 'index-pattern', id: 'stocks-*', title: 'stocks-*', timeFieldName: '@timestamp', - notExpandable: true + notExpandable: true, + references: [], } ] }; diff --git a/src/server/saved_objects/routes/get.test.js b/src/server/saved_objects/routes/get.test.js index 63246bcf3a329c..52ccb38601fb7d 100644 --- a/src/server/saved_objects/routes/get.test.js +++ b/src/server/saved_objects/routes/get.test.js @@ -53,7 +53,8 @@ describe('GET /api/saved_objects/{type}/{id}', () => { id: 'logstash-*', title: 'logstash-*', timeFieldName: '@timestamp', - notExpandable: true + notExpandable: true, + references: [], }; savedObjectsClient.get.returns(Promise.resolve(clientResponse)); diff --git a/src/server/saved_objects/routes/update.js b/src/server/saved_objects/routes/update.js index cc7e2c0a4b4d16..163fb77d5b62fc 100644 --- a/src/server/saved_objects/routes/update.js +++ b/src/server/saved_objects/routes/update.js @@ -32,14 +32,22 @@ export const createUpdateRoute = (prereqs) => { }).required(), payload: Joi.object({ attributes: Joi.object().required(), - version: Joi.number().min(1) + version: Joi.number().min(1), + references: Joi.array().items( + Joi.object() + .keys({ + name: Joi.string().required(), + type: Joi.string().required(), + id: Joi.string().required(), + }), + ).default([]), }).required() }, handler(request) { const { savedObjectsClient } = request.pre; const { type, id } = request.params; - const { attributes, version } = request.payload; - const options = { version }; + const { attributes, version, references } = request.payload; + const options = { version, references }; return savedObjectsClient.update(type, id, attributes, options); } diff --git a/src/server/saved_objects/routes/update.test.js b/src/server/saved_objects/routes/update.test.js index ac4d938794fda7..72a540979c2dee 100644 --- a/src/server/saved_objects/routes/update.test.js +++ b/src/server/saved_objects/routes/update.test.js @@ -51,7 +51,8 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => { payload: { attributes: { title: 'Testing' - } + }, + references: [], } }; @@ -66,7 +67,7 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => { it('calls upon savedObjectClient.update', async () => { const attributes = { title: 'Testing' }; - const options = { version: 2 }; + const options = { version: 2, references: [] }; const request = { method: 'PUT', url: '/api/saved_objects/index-pattern/logstash-*', diff --git a/src/server/saved_objects/serialization/index.ts b/src/server/saved_objects/serialization/index.ts index 09b8ddd94325a7..94807ddeb76680 100644 --- a/src/server/saved_objects/serialization/index.ts +++ b/src/server/saved_objects/serialization/index.ts @@ -43,13 +43,22 @@ export interface MigrationVersion { [type: string]: string; } +/** + * A reference object to anohter saved object. + */ +export interface SavedObjectReference { + name: string; + type: string; + id: string; +} + /** * A saved object type definition that allows for miscellaneous, unknown * properties, as current discussions around security, ACLs, etc indicate * that future props are likely to be added. Migrations support this * scenario out of the box. */ -export interface SavedObjectDoc { +interface SavedObjectDoc { attributes: object; id: string; type: string; @@ -61,6 +70,19 @@ export interface SavedObjectDoc { [rootProp: string]: any; } +interface Referencable { + references: SavedObjectReference[]; +} + +/** + * We want to have two types, one that guarantees a "references" attribute + * will exist and one that allows it to be null. Since we're not migrating + * all the saved objects to have a "references" array, we need to support + * the scenarios where it may be missing (ex migrations). + */ +export type RawSavedObjectDoc = SavedObjectDoc & Partial; +export type SanitizedSavedObjectDoc = SavedObjectDoc & Referencable; + function assertNonEmptyString(value: string, name: string) { if (!value || typeof value !== 'string') { throw new TypeError(`Expected "${value}" to be a ${name}`); @@ -94,13 +116,14 @@ export class SavedObjectsSerializer { * * @param {RawDoc} rawDoc - The raw ES document to be converted to saved object format. */ - public rawToSavedObject({ _id, _source, _version }: RawDoc): SavedObjectDoc { + public rawToSavedObject({ _id, _source, _version }: RawDoc): SanitizedSavedObjectDoc { const { type, namespace } = _source; return { type, id: this.trimIdPrefix(namespace, type, _id), ...(namespace && !this.schema.isNamespaceAgnostic(type) && { namespace }), attributes: _source[type], + references: _source.references || [], ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), ...(_source.updated_at && { updated_at: _source.updated_at }), ...(_version != null && { version: _version }), @@ -110,13 +133,23 @@ export class SavedObjectsSerializer { /** * Converts a document from the saved object client format to the format that is stored in elasticsearch. * - * @param {SavedObjectDoc} savedObj - The saved object to be converted to raw ES format. + * @param {SanitizedSavedObjectDoc} savedObj - The saved object to be converted to raw ES format. */ - public savedObjectToRaw(savedObj: SavedObjectDoc): RawDoc { - const { id, type, namespace, attributes, migrationVersion, updated_at, version } = savedObj; + public savedObjectToRaw(savedObj: SanitizedSavedObjectDoc): RawDoc { + const { + id, + type, + namespace, + attributes, + migrationVersion, + updated_at, + version, + references, + } = savedObj; const source = { [type]: attributes, type, + references, ...(namespace && !this.schema.isNamespaceAgnostic(type) && { namespace }), ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), diff --git a/src/server/saved_objects/serialization/serialization.test.ts b/src/server/saved_objects/serialization/serialization.test.ts index 43ff4a2df72525..2178105fc289e4 100644 --- a/src/server/saved_objects/serialization/serialization.test.ts +++ b/src/server/saved_objects/serialization/serialization.test.ts @@ -34,6 +34,24 @@ describe('saved object conversion', () => { expect(actual).toHaveProperty('type', 'foo'); }); + test('it copies the _source.references property to references', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], + }, + }); + expect(actual).toHaveProperty('references', [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'pattern*', + }, + ]); + }); + test('if specified it copies the _source.migrationVersion property to migrationVersion', () => { const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); const actual = serializer.rawToSavedObject({ @@ -95,6 +113,7 @@ describe('saved object conversion', () => { acl: '33.3.5', }, updated_at: now, + references: [], }; expect(expected).toEqual(actual); }); @@ -166,6 +185,7 @@ describe('saved object conversion', () => { attributes: { world: 'earth', }, + references: [], }); }); @@ -180,6 +200,7 @@ describe('saved object conversion', () => { expect(actual).toEqual({ id: 'universe', type: 'hello', + references: [], }); }); @@ -214,6 +235,7 @@ describe('saved object conversion', () => { }, namespace: 'foo-namespace', updated_at: new Date(), + references: [], }, }; @@ -385,6 +407,23 @@ describe('saved object conversion', () => { expect(actual._source).toHaveProperty('type', 'foo'); }); + test('it copies the references property to _source.references', () => { + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); + const actual = serializer.savedObjectToRaw({ + id: '1', + type: 'foo', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], + }); + expect(actual._source).toHaveProperty('references', [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'pattern*', + }, + ]); + }); + test('if specified it copies the updated_at property to _source.updated_at', () => { const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); const now = new Date(); diff --git a/src/server/saved_objects/service/lib/repository.js b/src/server/saved_objects/service/lib/repository.js index c047b6d72edc1f..01b79b4050a554 100644 --- a/src/server/saved_objects/service/lib/repository.js +++ b/src/server/saved_objects/service/lib/repository.js @@ -72,6 +72,7 @@ export class SavedObjectsRepository { * @property {boolean} [options.overwrite=false] * @property {object} [options.migrationVersion=undefined] * @property {string} [options.namespace] + * @property {array} [options.references] - [{ name, type, id }] * @returns {promise} - { id, type, version, attributes } */ async create(type, attributes = {}, options = {}) { @@ -80,6 +81,7 @@ export class SavedObjectsRepository { migrationVersion, overwrite = false, namespace, + references = [], } = options; const method = id && !overwrite ? 'create' : 'index'; @@ -93,6 +95,7 @@ export class SavedObjectsRepository { attributes, migrationVersion, updated_at: time, + references, }); const raw = this._serializer.savedObjectToRaw(migrated); @@ -122,11 +125,11 @@ export class SavedObjectsRepository { /** * Creates multiple documents at once * - * @param {array} objects - [{ type, id, attributes, migrationVersion }] + * @param {array} objects - [{ type, id, attributes, references, migrationVersion }] * @param {object} [options={}] * @property {boolean} [options.overwrite=false] - overwrites existing documents * @property {string} [options.namespace] - * @returns {promise} - {saved_objects: [[{ id, type, version, attributes, error: { message } }]} + * @returns {promise} - {saved_objects: [[{ id, type, version, references, attributes, error: { message } }]} */ async bulkCreate(objects, options = {}) { const { @@ -143,6 +146,7 @@ export class SavedObjectsRepository { migrationVersion: object.migrationVersion, namespace, updated_at: time, + references: object.references || [], }); const raw = this._serializer.savedObjectToRaw(migrated); @@ -178,6 +182,7 @@ export class SavedObjectsRepository { id = responseId, type, attributes, + references = [], } = objects[i]; if (error) { @@ -202,7 +207,8 @@ export class SavedObjectsRepository { type, updated_at: time, version, - attributes + attributes, + references, }; }) }; @@ -292,6 +298,7 @@ export class SavedObjectsRepository { * @property {string} [options.sortOrder] * @property {Array} [options.fields] * @property {string} [options.namespace] + * @property {object} [options.hasReference] - { type, id } * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ async find(options = {}) { @@ -300,6 +307,7 @@ export class SavedObjectsRepository { search, defaultSearchOperator = 'OR', searchFields, + hasReference, page = 1, perPage = 20, sortField, @@ -337,6 +345,7 @@ export class SavedObjectsRepository { sortField, sortOrder, namespace, + hasReference, }) } }; @@ -414,6 +423,7 @@ export class SavedObjectsRepository { ...time && { updated_at: time }, version: doc._version, attributes: doc._source[type], + references: doc._source.references || [], migrationVersion: doc._source.migrationVersion, }; }) @@ -456,6 +466,7 @@ export class SavedObjectsRepository { ...updatedAt && { updated_at: updatedAt }, version: response._version, attributes: response._source[type], + references: response._source.references || [], migrationVersion: response._source.migrationVersion, }; } @@ -468,12 +479,14 @@ export class SavedObjectsRepository { * @param {object} [options={}] * @property {integer} options.version - ensures version matches that of persisted object * @property {string} [options.namespace] + * @property {array} [options.references] - [{ name, type, id }] * @returns {promise} */ async update(type, id, attributes, options = {}) { const { version, - namespace + namespace, + references = [], } = options; const time = this._getCurrentTime(); @@ -488,6 +501,7 @@ export class SavedObjectsRepository { doc: { [type]: attributes, updated_at: time, + references, } }, }); @@ -502,6 +516,7 @@ export class SavedObjectsRepository { type, updated_at: time, version: response._version, + references, attributes }; } @@ -575,6 +590,7 @@ export class SavedObjectsRepository { id, type, updated_at: time, + references: response.get._source.references, version: response._version, attributes: response.get._source[type], }; diff --git a/src/server/saved_objects/service/lib/repository.test.js b/src/server/saved_objects/service/lib/repository.test.js index 2862785694211d..c5e1d609d2d580 100644 --- a/src/server/saved_objects/service/lib/repository.test.js +++ b/src/server/saved_objects/service/lib/repository.test.js @@ -259,6 +259,11 @@ describe('SavedObjectsRepository', () => { }, { id: 'logstash-*', namespace: 'foo-namespace', + references: [{ + name: 'ref_0', + type: 'test', + id: '123', + }], }); expect(response).toEqual({ @@ -268,7 +273,12 @@ describe('SavedObjectsRepository', () => { version: 2, attributes: { title: 'Logstash', - } + }, + references: [{ + name: 'ref_0', + type: 'test', + id: '123', + }], }); }); @@ -428,8 +438,8 @@ describe('SavedObjectsRepository', () => { callAdminCluster.returns({ items: [] }); await savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } } + { type: 'config', id: 'one', attributes: { title: 'Test One' }, references: [{ name: 'ref_0', type: 'test', id: '1' }] }, + { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' }, references: [{ name: 'ref_0', type: 'test', id: '2' }] }, ]); sinon.assert.calledOnce(callAdminCluster); @@ -439,9 +449,14 @@ describe('SavedObjectsRepository', () => { expect(bulkCalls[0][1].body).toEqual([ { create: { _type: '_doc', _id: 'config:one' } }, - { type: 'config', ...mockTimestampFields, config: { title: 'Test One' } }, + { type: 'config', ...mockTimestampFields, config: { title: 'Test One' }, references: [{ name: 'ref_0', type: 'test', id: '1' }] }, { create: { _type: '_doc', _id: 'index-pattern:two' } }, - { type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } } + { + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }, ]); sinon.assert.calledOnce(onBeforeWrite); @@ -463,9 +478,21 @@ describe('SavedObjectsRepository', () => { sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({ body: [ { create: { _type: '_doc', _id: 'config:one' } }, - { type: 'config', ...mockTimestampFields, config: { title: 'Test One!!' }, migrationVersion: { foo: '2.3.4' } }, + { + type: 'config', + ...mockTimestampFields, + config: { title: 'Test One!!' }, + migrationVersion: { foo: '2.3.4' }, + references: [], + }, { create: { _type: '_doc', _id: 'index-pattern:two' } }, - { type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two!!' }, migrationVersion: { foo: '2.3.4' } } + { + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { title: 'Test Two!!' }, + migrationVersion: { foo: '2.3.4' }, + references: [], + }, ] })); }); @@ -479,7 +506,7 @@ describe('SavedObjectsRepository', () => { body: [ // uses create because overwriting is not allowed { create: { _type: '_doc', _id: 'foo:bar' } }, - { type: 'foo', ...mockTimestampFields, 'foo': {} }, + { type: 'foo', ...mockTimestampFields, 'foo': {}, references: [] }, ] })); @@ -494,7 +521,7 @@ describe('SavedObjectsRepository', () => { body: [ // uses index because overwriting is allowed { index: { _type: '_doc', _id: 'foo:bar' } }, - { type: 'foo', ...mockTimestampFields, 'foo': {} }, + { type: 'foo', ...mockTimestampFields, 'foo': {}, references: [] }, ] })); @@ -538,6 +565,7 @@ describe('SavedObjectsRepository', () => { version: 2, ...mockTimestampFields, attributes: { title: 'Test Two' }, + references: [], } ] }); @@ -576,12 +604,14 @@ describe('SavedObjectsRepository', () => { version: 2, ...mockTimestampFields, attributes: { title: 'Test One' }, + references: [], }, { id: 'two', type: 'index-pattern', version: 2, ...mockTimestampFields, attributes: { title: 'Test Two' }, + references: [], } ] }); @@ -602,9 +632,21 @@ describe('SavedObjectsRepository', () => { sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({ body: [ { create: { _type: '_doc', _id: 'foo-namespace:config:one' } }, - { namespace: 'foo-namespace', type: 'config', ...mockTimestampFields, config: { title: 'Test One' } }, + { + namespace: 'foo-namespace', + type: 'config', + ...mockTimestampFields, + config: { title: 'Test One' }, + references: [], + }, { create: { _type: '_doc', _id: 'foo-namespace:index-pattern:two' } }, - { namespace: 'foo-namespace', type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } } + { + namespace: 'foo-namespace', + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { title: 'Test Two' }, + references: [], + }, ] })); sinon.assert.calledOnce(onBeforeWrite); @@ -620,9 +662,9 @@ describe('SavedObjectsRepository', () => { sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({ body: [ { create: { _type: '_doc', _id: 'config:one' } }, - { type: 'config', ...mockTimestampFields, config: { title: 'Test One' } }, + { type: 'config', ...mockTimestampFields, config: { title: 'Test One' }, references: [] }, { create: { _type: '_doc', _id: 'index-pattern:two' } }, - { type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' } } + { type: 'index-pattern', ...mockTimestampFields, 'index-pattern': { title: 'Test Two' }, references: [] } ] })); sinon.assert.calledOnce(onBeforeWrite); @@ -642,7 +684,7 @@ describe('SavedObjectsRepository', () => { sinon.assert.calledWithExactly(callAdminCluster, 'bulk', sinon.match({ body: [ { create: { _type: '_doc', _id: 'globaltype:one' } }, - { type: 'globaltype', ...mockTimestampFields, 'globaltype': { title: 'Test One' } }, + { type: 'globaltype', ...mockTimestampFields, 'globaltype': { title: 'Test One' }, references: [] }, ] })); sinon.assert.calledOnce(onBeforeWrite); @@ -812,22 +854,29 @@ describe('SavedObjectsRepository', () => { } }); - it('passes mappings, schema, search, defaultSearchOperator, searchFields, type, sortField, and sortOrder to getSearchDsl', async () => { - callAdminCluster.returns(namespacedSearchResults); - const relevantOpts = { - namespace: 'foo-namespace', - search: 'foo*', - searchFields: ['foo'], - type: 'bar', - sortField: 'name', - sortOrder: 'desc', - defaultSearchOperator: 'AND', - }; + it( + 'passes mappings, schema, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl', + async () => { + callAdminCluster.returns(namespacedSearchResults); + const relevantOpts = { + namespace: 'foo-namespace', + search: 'foo*', + searchFields: ['foo'], + type: 'bar', + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + }; - await savedObjectsRepository.find(relevantOpts); - sinon.assert.calledOnce(getSearchDsl); - sinon.assert.calledWithExactly(getSearchDsl, mappings, schema, relevantOpts); - }); + await savedObjectsRepository.find(relevantOpts); + sinon.assert.calledOnce(getSearchDsl); + sinon.assert.calledWithExactly(getSearchDsl, mappings, schema, relevantOpts); + } + ); it('merges output of getSearchDsl into es request body', async () => { callAdminCluster.returns(noNamespaceSearchResults); @@ -858,7 +907,8 @@ describe('SavedObjectsRepository', () => { type: doc._source.type, ...mockTimestampFields, version: doc._version, - attributes: doc._source[doc._source.type] + attributes: doc._source[doc._source.type], + references: [], }); }); }); @@ -881,7 +931,8 @@ describe('SavedObjectsRepository', () => { type: doc._source.type, ...mockTimestampFields, version: doc._version, - attributes: doc._source[doc._source.type] + attributes: doc._source[doc._source.type], + references: [], }); }); }); @@ -970,7 +1021,8 @@ describe('SavedObjectsRepository', () => { version: 2, attributes: { title: 'Testing' - } + }, + references: [], }); }); @@ -985,7 +1037,8 @@ describe('SavedObjectsRepository', () => { version: 2, attributes: { title: 'Testing' - } + }, + references: [], }); }); @@ -1132,7 +1185,8 @@ describe('SavedObjectsRepository', () => { type: 'config', ...mockTimestampFields, version: 2, - attributes: { title: 'Test' } + attributes: { title: 'Test' }, + references: [], }); expect(savedObjects[1]).toEqual({ id: 'bad', @@ -1168,13 +1222,25 @@ describe('SavedObjectsRepository', () => { }); it('returns current ES document version', async () => { - const response = await savedObjectsRepository.update('index-pattern', 'logstash-*', attributes, { namespace: 'foo-namespace' }); + const response = await savedObjectsRepository.update('index-pattern', 'logstash-*', attributes, { + namespace: 'foo-namespace', + references: [{ + name: 'ref_0', + type: 'test', + id: '1', + }], + }); expect(response).toEqual({ id, type, ...mockTimestampFields, version: newVersion, - attributes + attributes, + references: [{ + name: 'ref_0', + type: 'test', + id: '1', + }], }); }); @@ -1197,6 +1263,11 @@ describe('SavedObjectsRepository', () => { title: 'Testing', }, { namespace: 'foo-namespace', + references: [{ + name: 'ref_0', + type: 'test', + id: '1', + }], }); sinon.assert.calledOnce(callAdminCluster); @@ -1205,7 +1276,15 @@ describe('SavedObjectsRepository', () => { id: 'foo-namespace:index-pattern:logstash-*', version: undefined, body: { - doc: { updated_at: mockTimestamp, 'index-pattern': { title: 'Testing' } } + doc: { + updated_at: mockTimestamp, + 'index-pattern': { title: 'Testing' }, + references: [{ + name: 'ref_0', + type: 'test', + id: '1', + }], + }, }, ignore: [404], refresh: 'wait_for', @@ -1216,7 +1295,15 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - await savedObjectsRepository.update('index-pattern', 'logstash-*', { title: 'Testing' }); + await savedObjectsRepository.update('index-pattern', 'logstash-*', { + title: 'Testing', + }, { + references: [{ + name: 'ref_0', + type: 'test', + id: '1', + }], + }); sinon.assert.calledOnce(callAdminCluster); sinon.assert.calledWithExactly(callAdminCluster, 'update', { @@ -1224,7 +1311,15 @@ describe('SavedObjectsRepository', () => { id: 'index-pattern:logstash-*', version: undefined, body: { - doc: { updated_at: mockTimestamp, 'index-pattern': { title: 'Testing' } } + doc: { + updated_at: mockTimestamp, + 'index-pattern': { title: 'Testing' }, + references: [{ + name: 'ref_0', + type: 'test', + id: '1', + }], + }, }, ignore: [404], refresh: 'wait_for', @@ -1239,6 +1334,11 @@ describe('SavedObjectsRepository', () => { name: 'bar', }, { namespace: 'foo-namespace', + references: [{ + name: 'ref_0', + type: 'test', + id: '1', + }], }); sinon.assert.calledOnce(callAdminCluster); @@ -1247,7 +1347,15 @@ describe('SavedObjectsRepository', () => { id: 'globaltype:foo', version: undefined, body: { - doc: { updated_at: mockTimestamp, 'globaltype': { name: 'bar' } } + doc: { + updated_at: mockTimestamp, + 'globaltype': { name: 'bar' }, + references: [{ + name: 'ref_0', + type: 'test', + id: '1', + }], + }, }, ignore: [404], refresh: 'wait_for', diff --git a/src/server/saved_objects/service/lib/search_dsl/query_params.js b/src/server/saved_objects/service/lib/search_dsl/query_params.js index 47e5812e5eb2e2..cd316593dde9e1 100644 --- a/src/server/saved_objects/service/lib/search_dsl/query_params.js +++ b/src/server/saved_objects/service/lib/search_dsl/query_params.js @@ -99,13 +99,37 @@ function getClauseForType(schema, namespace, type) { * @param {String} search * @param {Array} searchFields * @param {String} defaultSearchOperator + * @param {Object} hasReference * @return {Object} */ -export function getQueryParams(mappings, schema, namespace, type, search, searchFields, defaultSearchOperator) { +export function getQueryParams(mappings, schema, namespace, type, search, searchFields, defaultSearchOperator, hasReference) { const types = getTypes(mappings, type); const bool = { filter: [{ bool: { + must: hasReference + ? [{ + nested: { + path: 'references', + query: { + bool: { + must: [ + { + term: { + 'references.id': hasReference.id, + }, + }, + { + term: { + 'references.type': hasReference.type, + }, + }, + ], + }, + }, + }, + }] + : undefined, should: types.map(type => getClauseForType(schema, namespace, type)), minimum_should_match: 1 } diff --git a/src/server/saved_objects/service/lib/search_dsl/query_params.test.js b/src/server/saved_objects/service/lib/search_dsl/query_params.test.js index 8e98fe3a023cb6..1d0a8063be9929 100644 --- a/src/server/saved_objects/service/lib/search_dsl/query_params.test.js +++ b/src/server/saved_objects/service/lib/search_dsl/query_params.test.js @@ -778,4 +778,46 @@ describe('searchDsl/queryParams', () => { }); }); }); + + describe('type (plural, namespaced and global), hasReference', () => { + it('supports hasReference', () => { + expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], null, null, 'OR', { type: 'bar', id: '1' })) + .toEqual({ + query: { + bool: { + filter: [{ + bool: { + must: [{ + nested: { + path: 'references', + query: { + bool: { + must: [ + { + term: { + 'references.id': '1', + }, + }, + { + term: { + 'references.type': 'bar', + }, + }, + ], + }, + }, + }, + }], + should: [ + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1, + } + }] + } + } + }); + }); + }); }); diff --git a/src/server/saved_objects/service/lib/search_dsl/search_dsl.js b/src/server/saved_objects/service/lib/search_dsl/search_dsl.js index d6a224f4c38577..3a11cd0c8e91d5 100644 --- a/src/server/saved_objects/service/lib/search_dsl/search_dsl.js +++ b/src/server/saved_objects/service/lib/search_dsl/search_dsl.js @@ -31,6 +31,7 @@ export function getSearchDsl(mappings, schema, options = {}) { sortField, sortOrder, namespace, + hasReference, } = options; if (!type) { @@ -42,7 +43,7 @@ export function getSearchDsl(mappings, schema, options = {}) { } return { - ...getQueryParams(mappings, schema, namespace, type, search, searchFields, defaultSearchOperator), + ...getQueryParams(mappings, schema, namespace, type, search, searchFields, defaultSearchOperator, hasReference), ...getSortingParams(mappings, type, sortField, sortOrder), }; } diff --git a/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js b/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js index 1ba780fc79ed00..0600c01848346a 100644 --- a/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js +++ b/src/server/saved_objects/service/lib/search_dsl/search_dsl.test.js @@ -46,7 +46,7 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, schema, namespace, type, search, searchFields) to getQueryParams', () => { + it('passes (mappings, schema, namespace, type, search, searchFields, hasReference) to getQueryParams', () => { const spy = sandbox.spy(queryParamsNS, 'getQueryParams'); const mappings = { type: { properties: {} } }; const schema = { isNamespaceAgnostic: () => {} }; @@ -56,6 +56,10 @@ describe('getSearchDsl', () => { search: 'bar', searchFields: ['baz'], defaultSearchOperator: 'AND', + hasReference: { + type: 'bar', + id: '1', + }, }; getSearchDsl(mappings, schema, opts); @@ -69,6 +73,7 @@ describe('getSearchDsl', () => { opts.search, opts.searchFields, opts.defaultSearchOperator, + opts.hasReference, ); }); diff --git a/src/server/saved_objects/service/saved_objects_client.d.ts b/src/server/saved_objects/service/saved_objects_client.d.ts index a6e10aa1b85b10..a58cec023b3359 100644 --- a/src/server/saved_objects/service/saved_objects_client.d.ts +++ b/src/server/saved_objects/service/saved_objects_client.d.ts @@ -36,7 +36,7 @@ export interface BulkCreateObject { } export interface BulkCreateResponse { - savedObjects: Array>; + saved_objects: Array>; } export interface FindOptions extends BaseOptions { @@ -68,7 +68,7 @@ export interface BulkGetObject { export type BulkGetObjects = BulkGetObject[]; export interface BulkGetResponse { - savedObjects: Array>; + saved_objects: Array>; } export interface SavedObjectAttributes { @@ -84,6 +84,13 @@ export interface SavedObject { message: string; }; attributes: T; + references: SavedObjectReference[]; +} + +export interface SavedObjectReference { + name: string; + type: string; + id: string; } export declare class SavedObjectsClient { diff --git a/src/server/saved_objects/service/saved_objects_client.js b/src/server/saved_objects/service/saved_objects_client.js index a354067e6f702b..a18881ad09d41a 100644 --- a/src/server/saved_objects/service/saved_objects_client.js +++ b/src/server/saved_objects/service/saved_objects_client.js @@ -148,6 +148,7 @@ export class SavedObjectsClient { * @property {string} [options.sortOrder] * @property {Array} [options.fields] * @property {string} [options.namespace] + * @property {object} [options.hasReference] - { type, id } * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ async find(options = {}) { diff --git a/src/ui/public/courier/saved_object/__tests__/saved_object.js b/src/ui/public/courier/saved_object/__tests__/saved_object.js index 4d89ae5c19a665..9c0d092557b76a 100644 --- a/src/ui/public/courier/saved_object/__tests__/saved_object.js +++ b/src/ui/public/courier/saved_object/__tests__/saved_object.js @@ -285,6 +285,32 @@ describe('Saved Object', function () { }); }); }); + + describe('with extractReferences', () => { + it('calls the function', async () => { + const id = '123'; + stubESResponse(getMockedDocResponse('id')); + let extractReferencesCallCount = 0; + const extractReferences = ({ attributes, references }) => { + extractReferencesCallCount++; + return { attributes, references }; + }; + return createInitializedSavedObject({ type: 'dashboard', extractReferences }) + .then((savedObject) => { + sinon.stub(savedObjectsClientStub, 'create').callsFake(() => { + return BluebirdPromise.resolve({ + id, + version: 2, + type: 'dashboard', + }); + }); + return savedObject.save(); + }) + .then(() => { + expect(extractReferencesCallCount).to.be(1); + }); + }); + }); }); describe('applyESResp', function () { @@ -405,6 +431,73 @@ describe('Saved Object', function () { }); }); + it('does not inject references when references array is missing', async () => { + const injectReferences = sinon.stub(); + const config = { + type: 'dashboard', + injectReferences, + }; + const savedObject = new SavedObject(config); + return savedObject.init() + .then(() => { + const response = { + found: true, + _source: { + dinosaurs: { tRex: 'has big teeth' }, + }, + }; + return savedObject.applyESResp(response); + }) + .then(() => { + expect(injectReferences).to.have.property('notCalled', true); + }); + }); + + it('does not inject references when references array is empty', async () => { + const injectReferences = sinon.stub(); + const config = { + type: 'dashboard', + injectReferences, + }; + const savedObject = new SavedObject(config); + return savedObject.init() + .then(() => { + const response = { + found: true, + _source: { + dinosaurs: { tRex: 'has big teeth' }, + }, + references: [], + }; + return savedObject.applyESResp(response); + }) + .then(() => { + expect(injectReferences).to.have.property('notCalled', true); + }); + }); + + it('injects references when function is provided and references exist', async () => { + const injectReferences = sinon.stub(); + const config = { + type: 'dashboard', + injectReferences, + }; + const savedObject = new SavedObject(config); + return savedObject.init() + .then(() => { + const response = { + found: true, + _source: { + dinosaurs: { tRex: 'has big teeth' }, + }, + references: [{}], + }; + return savedObject.applyESResp(response); + }) + .then(() => { + expect(injectReferences).to.have.property('calledOnce', true); + }); + }); }); describe ('config', function () { diff --git a/src/ui/public/courier/saved_object/saved_object.js b/src/ui/public/courier/saved_object/saved_object.js index edbfa6b898f5c4..c3cf9be9491bbe 100644 --- a/src/ui/public/courier/saved_object/saved_object.js +++ b/src/ui/public/courier/saved_object/saved_object.js @@ -103,6 +103,8 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm const afterESResp = config.afterESResp || _.noop; const customInit = config.init || _.noop; + const extractReferences = config.extractReferences; + const injectReferences = config.injectReferences; // optional search source which this object configures this.searchSource = config.searchSource ? new SearchSource() : undefined; @@ -117,7 +119,7 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm // in favor of a better rename/save flow. this.copyOnSave = false; - const parseSearchSource = (searchSourceJson) => { + const parseSearchSource = (searchSourceJson, references) => { if (!this.searchSource) return; // if we have a searchSource, set its values based on the searchSourceJson field @@ -136,6 +138,16 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm throw new InvalidJSONProperty(`Invalid searchSourceJSON in ${esType} "${this.id}".`); } + // Inject index id if a reference is saved + if (searchSourceValues.indexRefName) { + const reference = references.find(reference => reference.name === searchSourceValues.indexRefName); + if (!reference) { + throw new Error(`Could not find reference for ${searchSourceValues.indexRefName} on ${this.getEsType()} ${this.id}`); + } + searchSourceValues.index = reference.id; + delete searchSourceValues.indexRefName; + } + const searchSourceFields = this.searchSource.getFields(); const fnProps = _.transform(searchSourceFields, function (dynamic, val, name) { if (_.isFunction(val)) dynamic[name] = val; @@ -213,6 +225,7 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm _id: resp.id, _type: resp.type, _source: _.cloneDeep(resp.attributes), + references: resp.references, found: resp._version ? true : false }; }) @@ -254,8 +267,13 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm this.lastSavedTitle = this.title; return Promise.try(() => { - parseSearchSource(meta.searchSourceJSON); + parseSearchSource(meta.searchSourceJSON, resp.references); return this.hydrateIndexPattern(); + }).then(() => { + if (injectReferences && resp.references && resp.references.length > 0) { + injectReferences(this, resp.references); + } + return this; }).then(() => { return Promise.cast(afterESResp.call(this, resp)); }); @@ -266,12 +284,13 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm * * @return {Object} */ - this.serialize = () => { - const body = {}; + this._serialize = () => { + const attributes = {}; + const references = []; _.forOwn(mapping, (fieldMapping, fieldName) => { if (this[fieldName] != null) { - body[fieldName] = (fieldMapping._serialize) + attributes[fieldName] = (fieldMapping._serialize) ? fieldMapping._serialize(this[fieldName]) : this[fieldName]; } @@ -279,12 +298,22 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm if (this.searchSource) { const searchSourceFields = _.omit(this.searchSource.getFields(), ['sort', 'size']); - body.kibanaSavedObjectMeta = { + if (searchSourceFields.index) { + const indexId = searchSourceFields.index; + searchSourceFields.indexRefName = 'kibanaSavedObjectMeta.searchSourceJSON.index'; + references.push({ + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexId, + }); + delete searchSourceFields.index; + } + attributes.kibanaSavedObjectMeta = { searchSourceJSON: angular.toJson(searchSourceFields) }; } - return body; + return { attributes, references }; }; /** @@ -304,16 +333,17 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm /** * Attempts to create the current object using the serialized source. If an object already * exists, a warning message requests an overwrite confirmation. - * @param source - serialized version of this object (return value from this.serialize()) + * @param source - serialized version of this object (return value from this._serialize()) * What will be indexed into elasticsearch. + * @param options - options to pass to the saved object create method * @returns {Promise} - A promise that is resolved with the objects id if the object is * successfully indexed. If the overwrite confirmation was rejected, an error is thrown with * a confirmRejected = true parameter so that case can be handled differently than * a create or index error. * @resolved {SavedObject} */ - const createSource = (source) => { - return savedObjectsClient.create(esType, source, this.creationOpts()) + const createSource = (source, options = {}) => { + return savedObjectsClient.create(esType, source, options) .catch(err => { // record exists, confirm overwriting if (_.get(err, 'res.status') === 409) { @@ -331,7 +361,7 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm values: { name: this.getDisplayName() } }), }) - .then(() => savedObjectsClient.create(esType, source, this.creationOpts({ overwrite: true }))) + .then(() => savedObjectsClient.create(esType, source, this.creationOpts({ overwrite: true, ...options }))) .catch(() => Promise.reject(new Error(OVERWRITE_REJECTED))); } return Promise.reject(err); @@ -406,16 +436,21 @@ export function SavedObjectProvider(Promise, Private, Notifier, confirmModalProm this.id = null; } - const source = this.serialize(); + // Here we want to extract references and set them within "references" attribute + let { attributes, references } = this._serialize(); + if (extractReferences) { + ({ attributes, references } = extractReferences({ attributes, references })); + } + if (!references) throw new Error('References not returned from extractReferences'); this.isSaving = true; return checkForDuplicateTitle(isTitleDuplicateConfirmed, onTitleDuplicate) .then(() => { if (confirmOverwrite) { - return createSource(source); + return createSource(attributes, this.creationOpts({ references })); } else { - return savedObjectsClient.create(esType, source, this.creationOpts({ overwrite: true })); + return savedObjectsClient.create(esType, attributes, this.creationOpts({ references, overwrite: true })); } }) .then((resp) => { diff --git a/src/ui/public/saved_objects/saved_object.js b/src/ui/public/saved_objects/saved_object.js index 9287b9e313d8f2..ed3bbfe9b0fb1a 100644 --- a/src/ui/public/saved_objects/saved_object.js +++ b/src/ui/public/saved_objects/saved_object.js @@ -20,11 +20,12 @@ import _ from 'lodash'; export class SavedObject { - constructor(client, { id, type, version, attributes, error, migrationVersion } = {}) { + constructor(client, { id, type, version, attributes, error, migrationVersion, references } = {}) { this._client = client; this.id = id; this.type = type; this.attributes = attributes || {}; + this.references = references || []; this._version = version; this.migrationVersion = migrationVersion; if (error) { @@ -46,9 +47,17 @@ export class SavedObject { save() { if (this.id) { - return this._client.update(this.type, this.id, this.attributes, { migrationVersion: this.migrationVersion }); + return this._client.update( + this.type, + this.id, + this.attributes, + { + migrationVersion: this.migrationVersion, + references: this.references, + }, + ); } else { - return this._client.create(this.type, this.attributes, { migrationVersion: this.migrationVersion }); + return this._client.create(this.type, this.attributes, { migrationVersion: this.migrationVersion, references: this.references }); } } diff --git a/src/ui/public/saved_objects/saved_objects_client.js b/src/ui/public/saved_objects/saved_objects_client.js index eba1acebabe4b3..0ee4c2ff47ad32 100644 --- a/src/ui/public/saved_objects/saved_objects_client.js +++ b/src/ui/public/saved_objects/saved_objects_client.js @@ -51,6 +51,7 @@ export class SavedObjectsClient { * @property {string} [options.id] - force id on creation, not recommended * @property {boolean} [options.overwrite=false] * @property {object} [options.migrationVersion] + * @property {array} [options.references] [{ name, type, id }] * @returns {promise} - SavedObject({ id, type, version, attributes }) */ create = (type, attributes = {}, options = {}) => { @@ -61,7 +62,17 @@ export class SavedObjectsClient { const path = this._getPath([type, options.id]); const query = _.pick(options, ['overwrite']); - return this._request({ method: 'POST', path, query, body: { attributes, migrationVersion: options.migrationVersion } }) + return this + ._request({ + method: 'POST', + path, + query, + body: { + attributes, + migrationVersion: options.migrationVersion, + references: options.references, + }, + }) .catch(error => { if (isAutoCreateIndexError(error)) { return showAutoCreateIndexErrorPage(); @@ -75,7 +86,7 @@ export class SavedObjectsClient { /** * Creates multiple documents at once * - * @param {array} objects - [{ type, id, attributes, migrationVersion }] + * @param {array} objects - [{ type, id, attributes, references, migrationVersion }] * @param {object} [options={}] * @property {boolean} [options.overwrite=false] * @returns {promise} - { savedObjects: [{ id, type, version, attributes, error: { message } }]} @@ -117,6 +128,7 @@ export class SavedObjectsClient { * @property {integer} [options.page=1] * @property {integer} [options.perPage=20] * @property {array} options.fields + * @property {object} [options.hasReference] - { type, id } * @returns {promise} - { savedObjects: [ SavedObject({ id, type, version, attributes }) ]} */ find = (options = {}) => { @@ -178,9 +190,10 @@ export class SavedObjectsClient { * @param {object} options * @prop {integer} options.version - ensures version matches that of persisted object * @prop {object} options.migrationVersion - The optional migrationVersion of this document + * @prop {array} option.references - the references of the saved object * @returns {promise} */ - update(type, id, attributes, { version, migrationVersion } = {}) { + update(type, id, attributes, { version, migrationVersion, references } = {}) { if (!type || !id || !attributes) { return Promise.reject(new Error('requires type, id and attributes')); } @@ -189,6 +202,7 @@ export class SavedObjectsClient { const body = { attributes, migrationVersion, + references, version }; diff --git a/test/api_integration/apis/management/saved_objects/relationships.js b/test/api_integration/apis/management/saved_objects/relationships.js index 45b0a8b82e4a26..e31a5fe3aaa7e8 100644 --- a/test/api_integration/apis/management/saved_objects/relationships.js +++ b/test/api_integration/apis/management/saved_objects/relationships.js @@ -40,8 +40,8 @@ export default function ({ getService }) { after(() => esArchiver.unload('management/saved_objects')); const SEARCH_RESPONSE_SCHEMA = Joi.object().keys({ - visualizations: GENERIC_RESPONSE_SCHEMA, - indexPatterns: GENERIC_RESPONSE_SCHEMA, + visualization: GENERIC_RESPONSE_SCHEMA, + 'index-pattern': GENERIC_RESPONSE_SCHEMA, }); describe('searches', async () => { @@ -61,13 +61,13 @@ export default function ({ getService }) { .expect(200) .then(resp => { expect(resp.body).to.eql({ - visualizations: [ + visualization: [ { id: 'a42c0580-3224-11e8-a572-ffca06da1357', title: 'VisualizationFromSavedSearch', }, ], - indexPatterns: [ + 'index-pattern': [ { id: '8963ca30-3224-11e8-a572-ffca06da1357', title: 'saved_objects*', @@ -85,7 +85,7 @@ export default function ({ getService }) { describe('dashboards', async () => { const DASHBOARD_RESPONSE_SCHEMA = Joi.object().keys({ - visualizations: GENERIC_RESPONSE_SCHEMA, + visualization: GENERIC_RESPONSE_SCHEMA, }); it('should validate dashboard response schema', async () => { @@ -104,7 +104,7 @@ export default function ({ getService }) { .expect(200) .then(resp => { expect(resp.body).to.eql({ - visualizations: [ + visualization: [ { id: 'add810b0-3224-11e8-a572-ffca06da1357', title: 'Visualization', @@ -128,7 +128,8 @@ export default function ({ getService }) { describe('visualizations', async () => { const VISUALIZATIONS_RESPONSE_SCHEMA = Joi.object().keys({ - dashboards: GENERIC_RESPONSE_SCHEMA, + dashboard: GENERIC_RESPONSE_SCHEMA, + search: GENERIC_RESPONSE_SCHEMA, }); it('should validate visualization response schema', async () => { @@ -147,7 +148,13 @@ export default function ({ getService }) { .expect(200) .then(resp => { expect(resp.body).to.eql({ - dashboards: [ + search: [ + { + id: '960372e0-3224-11e8-a572-ffca06da1357', + title: 'OneRecord' + }, + ], + dashboard: [ { id: 'b70c7ae0-3224-11e8-a572-ffca06da1357', title: 'Dashboard', @@ -166,8 +173,8 @@ export default function ({ getService }) { describe('index patterns', async () => { const INDEX_PATTERN_RESPONSE_SCHEMA = Joi.object().keys({ - searches: GENERIC_RESPONSE_SCHEMA, - visualizations: GENERIC_RESPONSE_SCHEMA, + search: GENERIC_RESPONSE_SCHEMA, + visualization: GENERIC_RESPONSE_SCHEMA, }); it('should validate visualization response schema', async () => { @@ -186,13 +193,13 @@ export default function ({ getService }) { .expect(200) .then(resp => { expect(resp.body).to.eql({ - searches: [ + search: [ { id: '960372e0-3224-11e8-a572-ffca06da1357', title: 'OneRecord', }, ], - visualizations: [ + visualization: [ { id: 'add810b0-3224-11e8-a572-ffca06da1357', title: 'Visualization', diff --git a/test/api_integration/apis/saved_objects/bulk_create.js b/test/api_integration/apis/saved_objects/bulk_create.js index 153dda4691fa66..074af9f775dde0 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.js +++ b/test/api_integration/apis/saved_objects/bulk_create.js @@ -69,7 +69,8 @@ export default function ({ getService }) { version: 1, attributes: { title: 'A great new dashboard' - } + }, + references: [], }, ] }); @@ -101,7 +102,8 @@ export default function ({ getService }) { version: 1, attributes: { title: 'An existing visualization' - } + }, + references: [], }, { type: 'dashboard', @@ -110,7 +112,8 @@ export default function ({ getService }) { version: 1, attributes: { title: 'A great new dashboard' - } + }, + references: [], }, ] }); diff --git a/test/api_integration/apis/saved_objects/bulk_get.js b/test/api_integration/apis/saved_objects/bulk_get.js index 68773e5124039b..da208a5cbe70ad 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.js +++ b/test/api_integration/apis/saved_objects/bulk_get.js @@ -68,7 +68,15 @@ export default function ({ getService }) { visState: resp.body.saved_objects[0].attributes.visState, uiStateJSON: resp.body.saved_objects[0].attributes.uiStateJSON, kibanaSavedObjectMeta: resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta - } + }, + migrationVersion: { + visualization: '7.0.0', + }, + references: [{ + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + }], }, { id: 'does not exist', @@ -86,7 +94,8 @@ export default function ({ getService }) { attributes: { buildNum: 8467, defaultIndex: '91200a00-9efd-11e7-acb3-3dab96693fab' - } + }, + references: [], } ] }); diff --git a/test/api_integration/apis/saved_objects/create.js b/test/api_integration/apis/saved_objects/create.js index 379539928ebafb..a4e66318fb01ee 100644 --- a/test/api_integration/apis/saved_objects/create.js +++ b/test/api_integration/apis/saved_objects/create.js @@ -54,7 +54,11 @@ export default function ({ getService }) { version: 1, attributes: { title: 'My favorite vis' - } + }, + migrationVersion: { + visualization: '7.0.0', + }, + references: [], }); }); }); @@ -95,7 +99,11 @@ export default function ({ getService }) { version: 1, attributes: { title: 'My favorite vis' - } + }, + migrationVersion: { + visualization: '7.0.0', + }, + references: [], }); }); diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index c9b1e9fc73f4a9..515f4501517bcc 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -45,7 +45,8 @@ export default function ({ getService }) { version: 1, attributes: { 'title': 'Count of requests' - } + }, + references: [], } ] }); diff --git a/test/api_integration/apis/saved_objects/get.js b/test/api_integration/apis/saved_objects/get.js index 22ea4798f4324a..0734918f5f3e4a 100644 --- a/test/api_integration/apis/saved_objects/get.js +++ b/test/api_integration/apis/saved_objects/get.js @@ -42,6 +42,9 @@ export default function ({ getService }) { type: 'visualization', updated_at: '2017-09-21T18:51:23.794Z', version: resp.body.version, + migrationVersion: { + visualization: '7.0.0', + }, attributes: { title: 'Count of requests', description: '', @@ -50,7 +53,12 @@ export default function ({ getService }) { visState: resp.body.attributes.visState, uiStateJSON: resp.body.attributes.uiStateJSON, kibanaSavedObjectMeta: resp.body.attributes.kibanaSavedObjectMeta - } + }, + references: [{ + type: 'index-pattern', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + }], }); }) )); diff --git a/test/api_integration/apis/saved_objects/migrations.js b/test/api_integration/apis/saved_objects/migrations.js index 260609d3b881e2..a4993658837f2d 100644 --- a/test/api_integration/apis/saved_objects/migrations.js +++ b/test/api_integration/apis/saved_objects/migrations.js @@ -85,11 +85,11 @@ export default ({ getService }) => { // The docs in the alias have been migrated assert.deepEqual(await fetchDocs({ callCluster, index }), [ - { id: 'bar:i', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 } }, - { id: 'bar:o', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 } }, - { id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' } }, - { id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' } }, - { id: 'foo:e', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' } }, + { id: 'bar:i', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [] }, + { id: 'bar:o', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [] }, + { id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' }, references: [] }, + { id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [] }, + { id: 'foo:e', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [] }, ]); }); @@ -131,10 +131,10 @@ export default ({ getService }) => { // The index for the initial migration has not been destroyed... assert.deepEqual(await fetchDocs({ callCluster, index: `${index}_2` }), [ - { id: 'bar:i', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 } }, - { id: 'bar:o', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 } }, - { id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' } }, - { id: 'foo:e', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' } }, + { id: 'bar:i', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 68 }, references: [] }, + { id: 'bar:o', type: 'bar', migrationVersion: { bar: '1.9.0' }, bar: { mynum: 6 }, references: [] }, + { id: 'foo:a', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOO A' }, references: [] }, + { id: 'foo:e', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'FOOEY' }, references: [] }, ]); // The docs were migrated again... @@ -144,15 +144,17 @@ export default ({ getService }) => { type: 'bar', migrationVersion: { bar: '2.3.4' }, bar: { mynum: 68, name: 'NAME i' }, + references: [], }, { id: 'bar:o', type: 'bar', migrationVersion: { bar: '2.3.4' }, bar: { mynum: 6, name: 'NAME o' }, + references: [], }, - { id: 'foo:a', type: 'foo', migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOO Av2' } }, - { id: 'foo:e', type: 'foo', migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOOEYv2' } }, + { id: 'foo:a', type: 'foo', migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOO Av2' }, references: [] }, + { id: 'foo:e', type: 'foo', migrationVersion: { foo: '2.0.1' }, foo: { name: 'FOOEYv2' }, references: [] }, ]); }); @@ -203,7 +205,7 @@ export default ({ getService }) => { // The docs in the alias have been migrated assert.deepEqual(await fetchDocs({ callCluster, index }), [ - { id: 'foo:lotr', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'LOTR' } }, + { id: 'foo:lotr', type: 'foo', migrationVersion: { foo: '1.0.0' }, foo: { name: 'LOTR' }, references: [] }, ]); }); }); diff --git a/test/api_integration/apis/saved_objects/update.js b/test/api_integration/apis/saved_objects/update.js index e6ca3d0317bf30..a00ab69783cb07 100644 --- a/test/api_integration/apis/saved_objects/update.js +++ b/test/api_integration/apis/saved_objects/update.js @@ -51,7 +51,8 @@ export default function ({ getService }) { version: 2, attributes: { title: 'My second favorite vis' - } + }, + references: [], }); }); }); diff --git a/x-pack/plugins/graph/index.js b/x-pack/plugins/graph/index.js index 83134f20d4f822..39dbc148772ec9 100644 --- a/x-pack/plugins/graph/index.js +++ b/x-pack/plugins/graph/index.js @@ -7,6 +7,7 @@ import { resolve } from 'path'; import Boom from 'boom'; +import migrations from './migrations'; import { initServer } from './server'; import mappings from './mappings.json'; @@ -28,7 +29,8 @@ export function graph(kibana) { styleSheetPaths: resolve(__dirname, 'public/index.scss'), hacks: ['plugins/graph/hacks/toggle_app_link_in_nav'], home: ['plugins/graph/register_feature'], - mappings + mappings, + migrations, }, config(Joi) { diff --git a/x-pack/plugins/graph/migrations.js b/x-pack/plugins/graph/migrations.js new file mode 100644 index 00000000000000..d454de49f2b006 --- /dev/null +++ b/x-pack/plugins/graph/migrations.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; + +export default { + 'graph-workspace': { + '7.0.0': (doc) => { + // Set new "references" attribute + doc.references = doc.references || []; + // Migrate index pattern + const wsState = get(doc, 'attributes.wsState'); + if (typeof wsState !== 'string') { + return doc; + } + let state; + try { + state = JSON.parse(JSON.parse(wsState)); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + return doc; + } + const { indexPattern } = state; + if (!indexPattern) { + return doc; + } + state.indexPatternRefName = 'indexPattern_0'; + delete state.indexPattern; + doc.attributes.wsState = JSON.stringify(JSON.stringify(state)); + doc.references.push({ + name: 'indexPattern_0', + type: 'index-pattern', + id: indexPattern, + }); + return doc; + } + } +}; diff --git a/x-pack/plugins/graph/migrations.test.js b/x-pack/plugins/graph/migrations.test.js new file mode 100644 index 00000000000000..93162d94857ced --- /dev/null +++ b/x-pack/plugins/graph/migrations.test.js @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import migrations from './migrations'; + +describe('graph-workspace', () => { + describe('7.0.0', () => { + const migration = migrations['graph-workspace']['7.0.0']; + + test('returns doc on empty object', () => { + expect(migration({})).toMatchInlineSnapshot(` +Object { + "references": Array [], +} +`); + }); + + test('returns doc when wsState is not a string', () => { + const doc = { + id: '1', + attributes: { + wsState: true, + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "wsState": true, + }, + "id": "1", + "references": Array [], +} +`); + }); + + test('returns doc when wsState is not valid JSON', () => { + const doc = { + id: '1', + attributes: { + wsState: '123abc', + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "wsState": "123abc", + }, + "id": "1", + "references": Array [], +} +`); + }); + + test('returns doc when "indexPattern" is missing from wsState', () => { + const doc = { + id: '1', + attributes: { + wsState: JSON.stringify(JSON.stringify({ foo: true })), + }, + }; + expect(migration(doc)).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "wsState": "\\"{\\\\\\"foo\\\\\\":true}\\"", + }, + "id": "1", + "references": Array [], +} +`); + }); + + test('extract "indexPattern" attribute from doc', () => { + const doc = { + id: '1', + attributes: { + wsState: JSON.stringify(JSON.stringify({ foo: true, indexPattern: 'pattern*' })), + bar: true, + }, + }; + const migratedDoc = migration(doc); + expect(migratedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "bar": true, + "wsState": "\\"{\\\\\\"foo\\\\\\":true,\\\\\\"indexPatternRefName\\\\\\":\\\\\\"indexPattern_0\\\\\\"}\\"", + }, + "id": "1", + "references": Array [ + Object { + "id": "pattern*", + "name": "indexPattern_0", + "type": "index-pattern", + }, + ], +} +`); + }); + }); +}); diff --git a/x-pack/plugins/graph/public/services/saved_workspace.js b/x-pack/plugins/graph/public/services/saved_workspace.js index eb0c0e1dd936ae..f25743900dab84 100644 --- a/x-pack/plugins/graph/public/services/saved_workspace.js +++ b/x-pack/plugins/graph/public/services/saved_workspace.js @@ -7,6 +7,10 @@ import { uiModules } from 'ui/modules'; import { SavedObjectProvider } from 'ui/courier'; import { i18n } from '@kbn/i18n'; +import { + extractReferences, + injectReferences, +} from './saved_workspace_references'; const module = uiModules.get('app/dashboard'); @@ -21,6 +25,8 @@ export function SavedWorkspaceProvider(Private) { type: SavedWorkspace.type, mapping: SavedWorkspace.mapping, searchSource: SavedWorkspace.searchsource, + extractReferences: extractReferences, + injectReferences: injectReferences, // if this is null/undefined then the SavedObject will be assigned the defaults id: id, diff --git a/x-pack/plugins/graph/public/services/saved_workspace_references.js b/x-pack/plugins/graph/public/services/saved_workspace_references.js new file mode 100644 index 00000000000000..a1b4254685c40a --- /dev/null +++ b/x-pack/plugins/graph/public/services/saved_workspace_references.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function extractReferences({ attributes, references = [] }) { + // For some reason, wsState comes in stringified 2x + const state = JSON.parse(JSON.parse(attributes.wsState)); + const { indexPattern } = state; + if (!indexPattern) { + throw new Error('indexPattern attribute is missing in "wsState"'); + } + state.indexPatternRefName = 'indexPattern_0'; + delete state.indexPattern; + return { + references: [ + ...references, + { + name: 'indexPattern_0', + type: 'index-pattern', + id: indexPattern, + } + ], + attributes: { + ...attributes, + wsState: JSON.stringify(JSON.stringify(state)), + }, + }; +} + +export function injectReferences(savedObject, references) { + // Skip if wsState is missing, at the time of development of this, there is no guarantee each + // saved object has wsState. + if (typeof savedObject.wsState !== 'string') { + return; + } + // Only need to parse / stringify once here compared to extractReferences + const state = JSON.parse(savedObject.wsState); + // Like the migration, skip injectReferences if "indexPatternRefName" is missing + if (!state.indexPatternRefName) { + return; + } + const indexPatternReference = references.find(reference => reference.name === state.indexPatternRefName); + if (!indexPatternReference) { + // Throw an error as "indexPatternRefName" means the reference exists within + // "references" and in this scenario we have bad data. + throw new Error(`Could not find reference "${state.indexPatternRefName}"`); + } + state.indexPattern = indexPatternReference.id; + delete state.indexPatternRefName; + savedObject.wsState = JSON.stringify(state); +} diff --git a/x-pack/plugins/graph/public/services/saved_workspace_references.test.js b/x-pack/plugins/graph/public/services/saved_workspace_references.test.js new file mode 100644 index 00000000000000..01eb7f9ead1f0e --- /dev/null +++ b/x-pack/plugins/graph/public/services/saved_workspace_references.test.js @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extractReferences, injectReferences } from './saved_workspace_references'; + +describe('extractReferences', () => { + test('extracts references from wsState', () => { + const doc = { + id: '1', + attributes: { + foo: true, + wsState: JSON.stringify( + JSON.stringify({ + indexPattern: 'pattern*', + bar: true, + }) + ), + }, + }; + const updatedDoc = extractReferences(doc); + expect(updatedDoc).toMatchInlineSnapshot(` +Object { + "attributes": Object { + "foo": true, + "wsState": "\\"{\\\\\\"bar\\\\\\":true,\\\\\\"indexPatternRefName\\\\\\":\\\\\\"indexPattern_0\\\\\\"}\\"", + }, + "references": Array [ + Object { + "id": "pattern*", + "name": "indexPattern_0", + "type": "index-pattern", + }, + ], +} +`); + }); + + test('fails when indexPattern is missing from workspace', () => { + const doc = { + id: '1', + attributes: { + wsState: JSON.stringify( + JSON.stringify({ + bar: true, + }) + ), + }, + }; + expect(() => extractReferences(doc)).toThrowErrorMatchingInlineSnapshot( + `"indexPattern attribute is missing in \\"wsState\\""` + ); + }); +}); + +describe('injectReferences', () => { + test('injects references into context', () => { + const context = { + id: '1', + foo: true, + wsState: JSON.stringify({ + indexPatternRefName: 'indexPattern_0', + bar: true, + }), + }; + const references = [ + { + name: 'indexPattern_0', + type: 'index-pattern', + id: 'pattern*', + }, + ]; + injectReferences(context, references); + expect(context).toMatchInlineSnapshot(` +Object { + "foo": true, + "id": "1", + "wsState": "{\\"bar\\":true,\\"indexPattern\\":\\"pattern*\\"}", +} +`); + }); + + test('skips when wsState is not a string', () => { + const context = { + id: '1', + foo: true, + }; + injectReferences(context, []); + expect(context).toMatchInlineSnapshot(` +Object { + "foo": true, + "id": "1", +} +`); + }); + + test('skips when indexPatternRefName is missing wsState', () => { + const context = { + id: '1', + wsState: JSON.stringify({ bar: true }), + }; + injectReferences(context, []); + expect(context).toMatchInlineSnapshot(` +Object { + "id": "1", + "wsState": "{\\"bar\\":true}", +} +`); + }); + + test(`fails when it can't find the reference in the array`, () => { + const context = { + id: '1', + wsState: JSON.stringify({ + indexPatternRefName: 'indexPattern_0', + }), + }; + expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot( + `"Could not find reference \\"indexPattern_0\\""` + ); + }); +}); diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/dashboard/ML-Apache2-Access-Remote-IP-Count-Explorer.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/dashboard/ML-Apache2-Access-Remote-IP-Count-Explorer.json index 5fc696c6c702d3..24b87e39b35dba 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/dashboard/ML-Apache2-Access-Remote-IP-Count-Explorer.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/dashboard/ML-Apache2-Access-Remote-IP-Count-Explorer.json @@ -7,6 +7,7 @@ "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Remote-IP-Timechart\",\"col\":1,\"row\":1},{\"size_x\":6,\"size_y\":3,\"panelIndex\":2,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Response-Code-Timechart\",\"col\":7,\"row\":1},{\"size_x\":6,\"size_y\":3,\"panelIndex\":3,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Top-Remote-IPs-Table\",\"col\":1,\"row\":4},{\"size_x\":6,\"size_y\":3,\"panelIndex\":4,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Map\",\"col\":7,\"row\":4},{\"size_x\":12,\"size_y\":9,\"panelIndex\":5,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Top-URLs-Table\",\"col\":1,\"row\":7}]", "optionsJSON": "{}", "version": 1, + "migrationVersion": {}, "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"filter\":[{\"query\":{\"query_string\":{\"analyze_wildcard\":true,\"query\":\"*\"}}}],\"highlightAll\":true,\"version\":true}" } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/dashboard/ML-Apache2-Remote-IP-URL-Explorer.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/dashboard/ML-Apache2-Remote-IP-URL-Explorer.json index b04050ceb6e19a..d4ef153201bf23 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/dashboard/ML-Apache2-Remote-IP-URL-Explorer.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/dashboard/ML-Apache2-Remote-IP-URL-Explorer.json @@ -7,6 +7,7 @@ "panelsJSON": "[{\"col\":1,\"id\":\"ML-Apache2-Access-Unique-Count-URL-Timechart\",\"panelIndex\":1,\"row\":1,\"size_x\":6,\"size_y\":3,\"type\":\"visualization\"},{\"col\":7,\"id\":\"ML-Apache2-Access-Response-Code-Timechart\",\"panelIndex\":2,\"row\":1,\"size_x\":6,\"size_y\":3,\"type\":\"visualization\"},{\"col\":1,\"id\":\"ML-Apache2-Access-Top-Remote-IPs-Table\",\"panelIndex\":3,\"row\":4,\"size_x\":6,\"size_y\":3,\"type\":\"visualization\"},{\"col\":7,\"id\":\"ML-Apache2-Access-Map\",\"panelIndex\":4,\"row\":4,\"size_x\":6,\"size_y\":3,\"type\":\"visualization\"},{\"size_x\":12,\"size_y\":8,\"panelIndex\":5,\"type\":\"visualization\",\"id\":\"ML-Apache2-Access-Top-URLs-Table\",\"col\":1,\"row\":7}]", "optionsJSON": "{}", "version": 1, + "migrationVersion": {}, "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"filter\":[{\"query\":{\"query_string\":{\"analyze_wildcard\":true,\"query\":\"*\"}}}],\"highlightAll\":true,\"version\":true}" } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/search/ML-Filebeat-Apache2-Access.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/search/ML-Filebeat-Apache2-Access.json index edb54752c2ffe2..7c6124295aae3b 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/search/ML-Filebeat-Apache2-Access.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/search/ML-Filebeat-Apache2-Access.json @@ -7,6 +7,7 @@ "description": "Filebeat Apache2 Access Data", "title": "ML Apache2 Access Data", "version": 1, + "migrationVersion": {}, "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"index\":\"INDEX_PATTERN_ID\",\"query\":{\"query_string\":{\"query\":\"_exists_:apache2.access\",\"analyze_wildcard\":true}},\"filter\":[],\"highlight\":{\"pre_tags\":[\"@kibana-highlighted-field@\"],\"post_tags\":[\"@/kibana-highlighted-field@\"],\"fields\":{\"*\":{}},\"require_field_match\":false,\"fragment_size\":2147483647}}" }, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Map.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Map.json index f782f329c037b1..c2df807f189855 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Map.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Map.json @@ -1,11 +1,12 @@ { - "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{},\"schema\":\"metric\",\"type\":\"count\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"autoPrecision\":true,\"field\":\"apache2.access.geoip.location\"},\"schema\":\"segment\",\"type\":\"geohash_grid\"}],\"listeners\":{},\"params\":{\"addTooltip\":true,\"heatBlur\":15,\"heatMaxZoom\":16,\"heatMinOpacity\":0.1,\"heatNormalizeData\":true,\"heatRadius\":25,\"isDesaturated\":true,\"legendPosition\":\"bottomright\",\"mapCenter\":[15,5],\"mapType\":\"Scaled Circle Markers\",\"mapZoom\":2,\"wms\":{\"enabled\":false,\"options\":{\"attribution\":\"Maps provided by USGS\",\"format\":\"image/png\",\"layers\":\"0\",\"styles\":\"\",\"transparent\":true,\"version\":\"1.3.0\"},\"url\":\"https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer\"}},\"title\":\"ML Apache2 Access Map\",\"type\":\"tile_map\"}", - "description": "", - "title": "ML Apache2 Access Map", - "uiStateJSON": "{\n \"mapCenter\": [\n 12.039320557540572,\n -0.17578125\n ]\n}", - "version": 1, - "savedSearchId": "ML-Filebeat-Apache2-Access", + "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{},\"schema\":\"metric\",\"type\":\"count\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"autoPrecision\":true,\"field\":\"apache2.access.geoip.location\"},\"schema\":\"segment\",\"type\":\"geohash_grid\"}],\"listeners\":{},\"params\":{\"addTooltip\":true,\"heatBlur\":15,\"heatMaxZoom\":16,\"heatMinOpacity\":0.1,\"heatNormalizeData\":true,\"heatRadius\":25,\"isDesaturated\":true,\"legendPosition\":\"bottomright\",\"mapCenter\":[15,5],\"mapType\":\"Scaled Circle Markers\",\"mapZoom\":2,\"wms\":{\"enabled\":false,\"options\":{\"attribution\":\"Maps provided by USGS\",\"format\":\"image/png\",\"layers\":\"0\",\"styles\":\"\",\"transparent\":true,\"version\":\"1.3.0\"},\"url\":\"https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer\"}},\"title\":\"ML Apache2 Access Map\",\"type\":\"tile_map\"}", + "description": "", + "title": "ML Apache2 Access Map", + "uiStateJSON": "{\n \"mapCenter\": [\n 12.039320557540572,\n -0.17578125\n ]\n}", + "version": 1, + "migrationVersion": {}, + "savedSearchId": "ML-Filebeat-Apache2-Access", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"filter\":[]}" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Remote-IP-Timechart.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Remote-IP-Timechart.json index dcceff40a2c0fc..0789ee1e03f6fd 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Remote-IP-Timechart.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Remote-IP-Timechart.json @@ -1,11 +1,12 @@ { - "visState": "{\"title\":\"ML Apache2 Access Remote IP Timechart\",\"type\":\"area\",\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"@timestamp per 5 minutes\"},\"type\":\"category\"}],\"defaultYExtents\":false,\"drawLinesBetweenPoints\":true,\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"interpolate\":\"linear\",\"legendPosition\":\"right\",\"radiusRatio\":9,\"scale\":\"linear\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Count\"},\"drawLinesBetweenPoints\":true,\"interpolate\":\"linear\",\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"area\",\"valueAxis\":\"ValueAxis-1\"}],\"setYExtents\":false,\"showCircles\":true,\"times\":[],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{},\"type\":\"value\"}]},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"apache2.access.remote_ip\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}", - "description": "", - "title": "ML Apache2 Access Remote IP Timechart", - "uiStateJSON": "{\"vis\":{\"legendOpen\":false}}", - "version": 1, - "savedSearchId": "ML-Filebeat-Apache2-Access", + "visState": "{\"title\":\"ML Apache2 Access Remote IP Timechart\",\"type\":\"area\",\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"@timestamp per 5 minutes\"},\"type\":\"category\"}],\"defaultYExtents\":false,\"drawLinesBetweenPoints\":true,\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"interpolate\":\"linear\",\"legendPosition\":\"right\",\"radiusRatio\":9,\"scale\":\"linear\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Count\"},\"drawLinesBetweenPoints\":true,\"interpolate\":\"linear\",\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"area\",\"valueAxis\":\"ValueAxis-1\"}],\"setYExtents\":false,\"showCircles\":true,\"times\":[],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{},\"type\":\"value\"}]},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"apache2.access.remote_ip\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}", + "description": "", + "title": "ML Apache2 Access Remote IP Timechart", + "uiStateJSON": "{\"vis\":{\"legendOpen\":false}}", + "version": 1, + "migrationVersion": {}, + "savedSearchId": "ML-Filebeat-Apache2-Access", "kibanaSavedObjectMeta": { "searchSourceJSON": "{}" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Response-Code-Timechart.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Response-Code-Timechart.json index f52019014b1388..bc0a22685d5cfb 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Response-Code-Timechart.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Response-Code-Timechart.json @@ -1,11 +1,12 @@ { - "visState": "{\"title\":\"ML Apache2 Access Response Code Timechart\",\"type\":\"histogram\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"scale\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"apache2.access.response_code\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}", - "description": "", - "title": "ML Apache2 Access Response Code Timechart", - "uiStateJSON": "{\n \"vis\": {\n \"colors\": {\n \"200\": \"#7EB26D\",\n \"404\": \"#614D93\"\n }\n }\n}", - "version": 1, - "savedSearchId": "ML-Filebeat-Apache2-Access", + "visState": "{\"title\":\"ML Apache2 Access Response Code Timechart\",\"type\":\"histogram\",\"params\":{\"shareYAxis\":true,\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"scale\":\"linear\",\"mode\":\"stacked\",\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false,\"yAxis\":{}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"apache2.access.response_code\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}", + "description": "", + "title": "ML Apache2 Access Response Code Timechart", + "uiStateJSON": "{\n \"vis\": {\n \"colors\": {\n \"200\": \"#7EB26D\",\n \"404\": \"#614D93\"\n }\n }\n}", + "version": 1, + "migrationVersion": {}, + "savedSearchId": "ML-Filebeat-Apache2-Access", "kibanaSavedObjectMeta": { "searchSourceJSON": "{\"filter\":[]}" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Top-Remote-IPs-Table.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Top-Remote-IPs-Table.json index 0c49d9de460010..38943fd9ee6acc 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Top-Remote-IPs-Table.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Top-Remote-IPs-Table.json @@ -1,11 +1,12 @@ { - "visState": "{\"title\":\"ML Apache2 Access Top Remote IPs Table\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"apache2.access.remote_ip\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}", - "description": "", - "title": "ML Apache2 Access Top Remote IPs Table", - "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", - "version": 1, - "savedSearchId": "ML-Filebeat-Apache2-Access", + "visState": "{\"title\":\"ML Apache2 Access Top Remote IPs Table\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"apache2.access.remote_ip\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}", + "description": "", + "title": "ML Apache2 Access Top Remote IPs Table", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "version": 1, + "migrationVersion": {}, + "savedSearchId": "ML-Filebeat-Apache2-Access", "kibanaSavedObjectMeta": { "searchSourceJSON": "{}" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Top-URLs-Table.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Top-URLs-Table.json index ab7760feff9996..406c314787c72a 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Top-URLs-Table.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Top-URLs-Table.json @@ -1,11 +1,12 @@ { - "visState": "{\"title\":\"ML Apache2 Access Top URLs Table\",\"type\":\"table\",\"params\":{\"perPage\":100,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"apache2.access.url\",\"size\":1000,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}", - "description": "", - "title": "ML Apache2 Access Top URLs Table", - "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", - "version": 1, - "savedSearchId": "ML-Filebeat-Apache2-Access", + "visState": "{\"title\":\"ML Apache2 Access Top URLs Table\",\"type\":\"table\",\"params\":{\"perPage\":100,\"showPartialRows\":false,\"showMetricsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"bucket\",\"params\":{\"field\":\"apache2.access.url\",\"size\":1000,\"order\":\"desc\",\"orderBy\":\"1\"}}],\"listeners\":{}}", + "description": "", + "title": "ML Apache2 Access Top URLs Table", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "version": 1, + "migrationVersion": {}, + "savedSearchId": "ML-Filebeat-Apache2-Access", "kibanaSavedObjectMeta": { "searchSourceJSON": "{}" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Unique-Count-URL-Timechart.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Unique-Count-URL-Timechart.json index c6ce4fcf741eb2..54d324434925c2 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Unique-Count-URL-Timechart.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/apache2/kibana/visualization/ML-Apache2-Access-Unique-Count-URL-Timechart.json @@ -1,11 +1,12 @@ { - "visState": "{\"title\":\"ML Apache2 Access Unique Count URL Timechart\",\"type\":\"line\",\"params\":{\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{\"text\":\"@timestamp per day\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Unique count of apache2.access.url\"}}],\"seriesParams\":[{\"show\":true,\"mode\":\"normal\",\"type\":\"line\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"lineWidth\":2,\"data\":{\"id\":\"1\",\"label\":\"Unique count of apache2.access.url\"},\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"showCircles\":true,\"interpolate\":\"linear\",\"scale\":\"linear\",\"drawLinesBetweenPoints\":true,\"radiusRatio\":9,\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"apache2.access.url\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}", - "description": "", - "title": "ML Apache2 Access Unique Count URL Timechart", - "uiStateJSON": "{}", - "version": 1, - "savedSearchId": "ML-Filebeat-Apache2-Access", + "visState": "{\"title\":\"ML Apache2 Access Unique Count URL Timechart\",\"type\":\"line\",\"params\":{\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{\"text\":\"@timestamp per day\"}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Unique count of apache2.access.url\"}}],\"seriesParams\":[{\"show\":true,\"mode\":\"normal\",\"type\":\"line\",\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"lineWidth\":2,\"data\":{\"id\":\"1\",\"label\":\"Unique count of apache2.access.url\"},\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"showCircles\":true,\"interpolate\":\"linear\",\"scale\":\"linear\",\"drawLinesBetweenPoints\":true,\"radiusRatio\":9,\"times\":[],\"addTimeMarker\":false,\"defaultYExtents\":false,\"setYExtents\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"apache2.access.url\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}],\"listeners\":{}}", + "description": "", + "title": "ML Apache2 Access Unique Count URL Timechart", + "uiStateJSON": "{}", + "version": 1, + "migrationVersion": {}, + "savedSearchId": "ML-Filebeat-Apache2-Access", "kibanaSavedObjectMeta": { "searchSourceJSON": "{}" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/rollup/server/usage/collector.js b/x-pack/plugins/rollup/server/usage/collector.js index 4cb9fba66ca672..d017740def9e3f 100644 --- a/x-pack/plugins/rollup/server/usage/collector.js +++ b/x-pack/plugins/rollup/server/usage/collector.js @@ -102,8 +102,9 @@ async function fetchRollupVisualizations(kibanaIndex, callCluster, rollupIndexPa index: kibanaIndex, ignoreUnavailable: true, filterPath: [ - 'hits.hits._source.visualization.savedSearchId', + 'hits.hits._source.visualization.savedSearchRefName', 'hits.hits._source.visualization.kibanaSavedObjectMeta', + 'hits.hits._source.references', ], body: { query: { @@ -128,19 +129,21 @@ async function fetchRollupVisualizations(kibanaIndex, callCluster, rollupIndexPa const { _source: { visualization: { - savedSearchId, + savedSearchRefName, kibanaSavedObjectMeta: { searchSourceJSON, }, }, + references = [], }, } = visualization; const searchSource = JSON.parse(searchSourceJSON); - if (savedSearchId) { + if (savedSearchRefName) { // This visualization depends upon a saved search. - if (rollupSavedSearchesToFlagMap[savedSearchId]) { + const savedSearch = references.find(ref => ref.name === savedSearchRefName); + if (rollupSavedSearchesToFlagMap[savedSearch.id]) { rollupVisualizations++; rollupVisualizationsFromSavedSearches++; } diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts index 5e86cd4f08c5eb..9a3203305b59d2 100644 --- a/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts @@ -149,6 +149,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClient { * @property {string} [options.sortOrder] * @property {Array} [options.fields] * @property {string} [options.namespace] + * @property {object} [options.hasReference] - { type, id } * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ public async find(options: FindOptions = {}) { diff --git a/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.ts b/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.ts index 9d9ad1417a3e9c..7604cbc06f7126 100644 --- a/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.ts +++ b/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.ts @@ -229,6 +229,7 @@ describe('interceptors', () => { attributes: { name: 'a space', }, + references: [], }, ]; @@ -265,6 +266,7 @@ describe('interceptors', () => { attributes: { name: 'a space', }, + references: [], }, ]; @@ -302,6 +304,7 @@ describe('interceptors', () => { attributes: { name: 'a space', }, + references: [], }, ]; @@ -348,6 +351,7 @@ describe('interceptors', () => { attributes: { name: 'Default Space', }, + references: [], }, ]; @@ -379,6 +383,7 @@ describe('interceptors', () => { attributes: { name: 'a space', }, + references: [], }, { id: 'b-space', @@ -386,6 +391,7 @@ describe('interceptors', () => { attributes: { name: 'b space', }, + references: [], }, ]; diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 6bf3413f3797d4..368384479bc30d 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -195,7 +195,7 @@ "description": "", "version": 1, "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"index\":\"91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + "searchSourceJSON": "{\"index\":\"space_1-91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" } } } @@ -216,7 +216,7 @@ "title": "Requests", "hits": 0, "description": "", - "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"space_1-dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", "optionsJSON": "{}", "uiStateJSON": "{}", "version": 1, @@ -291,7 +291,7 @@ "description": "", "version": 1, "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"index\":\"91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + "searchSourceJSON": "{\"index\":\"space_2-91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" } } } @@ -312,7 +312,7 @@ "title": "Requests", "hits": 0, "description": "", - "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"space_2-dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", "optionsJSON": "{}", "uiStateJSON": "{}", "version": 1, diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index 0d9e800c64003b..6dcaae760a58d3 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -88,6 +88,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: attributes: { title: 'A great new dashboard', }, + references: [], }, { type: 'globaltype', @@ -97,6 +98,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: attributes: { name: 'A new globaltype object', }, + references: [], }, { type: 'globaltype', diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts index fdffe545f34162..e82ea7b856a8c4 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts @@ -69,6 +69,7 @@ export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest) attributes: { name: 'My favorite global object', }, + references: [], }, ], }); @@ -99,6 +100,7 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) attributes: { title: 'Count of requests', }, + references: [], }, ], }); diff --git a/x-pack/test/saved_object_api_integration/common/suites/get.ts b/x-pack/test/saved_object_api_integration/common/suites/get.ts index 5bf2385544a7da..1cc36b411c61a7 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/get.ts @@ -69,6 +69,7 @@ export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest) attributes: { name: 'My favorite global object', }, + references: [], }); }; @@ -108,6 +109,13 @@ export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest) uiStateJSON: resp.body.attributes.uiStateJSON, kibanaSavedObjectMeta: resp.body.attributes.kibanaSavedObjectMeta, }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, + }, + ], }); }; diff --git a/x-pack/test/saved_object_api_integration/common/suites/update.ts b/x-pack/test/saved_object_api_integration/common/suites/update.ts index f936567200f54c..5d496ba58bbba4 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/update.ts @@ -78,6 +78,7 @@ export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest