Add upsert support for savedObjects update (#98712)

* Add upsert support for savedObjects update

* fix types

* update generated docs

* update docs

* fix types

* do not use update attributes for upsert
This commit is contained in:
Pierre Gayvallet 2021-04-30 11:10:50 +02:00 committed by GitHub
parent 6b6ad111c0
commit 05e2ab4df1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 184 additions and 45 deletions

View file

@ -36,6 +36,9 @@ WARNING: When you update, attributes are not validated, which allows you to pass
`references`::
(Optional, array) Objects with `name`, `id`, and `type` properties that describe the other saved objects this object references. To refer to the other saved object, use `name` in the attributes, but never the `id`, which automatically updates during migrations or import/export.
`upsert`::
(Optional, object) If specified, will create the document with the given upsert attributes if it doesn't exist.
[[saved-objects-api-update-errors-codes]]
==== Response code

View file

@ -32,5 +32,5 @@ The constructor for this class is marked as internal. Third-party code should no
| Method | Modifiers | Description |
| --- | --- | --- |
| [bulkUpdate(objects)](./kibana-plugin-core-public.savedobjectsclient.bulkupdate.md) | | Update multiple documents at once |
| [update(type, id, attributes, { version, migrationVersion, references })](./kibana-plugin-core-public.savedobjectsclient.update.md) | | Updates an object |
| [update(type, id, attributes, { version, references, upsert })](./kibana-plugin-core-public.savedobjectsclient.update.md) | | Updates an object |

View file

@ -9,7 +9,7 @@ Updates an object
<b>Signature:</b>
```typescript
update<T = unknown>(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise<SimpleSavedObject<T>>;
update<T = unknown>(type: string, id: string, attributes: T, { version, references, upsert }?: SavedObjectsUpdateOptions): Promise<SimpleSavedObject<T>>;
```
## Parameters
@ -19,7 +19,7 @@ update<T = unknown>(type: string, id: string, attributes: T, { version, migratio
| type | <code>string</code> | |
| id | <code>string</code> | |
| attributes | <code>T</code> | |
| { version, migrationVersion, references } | <code>SavedObjectsUpdateOptions</code> | |
| { version, references, upsert } | <code>SavedObjectsUpdateOptions</code> | |
<b>Returns:</b>

View file

@ -8,14 +8,14 @@
<b>Signature:</b>
```typescript
export interface SavedObjectsUpdateOptions
export interface SavedObjectsUpdateOptions<Attributes = unknown>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [migrationVersion](./kibana-plugin-core-public.savedobjectsupdateoptions.migrationversion.md) | <code>SavedObjectsMigrationVersion</code> | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. |
| [references](./kibana-plugin-core-public.savedobjectsupdateoptions.references.md) | <code>SavedObjectReference[]</code> | |
| [upsert](./kibana-plugin-core-public.savedobjectsupdateoptions.upsert.md) | <code>Attributes</code> | |
| [version](./kibana-plugin-core-public.savedobjectsupdateoptions.version.md) | <code>string</code> | |

View file

@ -1,13 +0,0 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsUpdateOptions](./kibana-plugin-core-public.savedobjectsupdateoptions.md) &gt; [migrationVersion](./kibana-plugin-core-public.savedobjectsupdateoptions.migrationversion.md)
## SavedObjectsUpdateOptions.migrationVersion property
Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value.
<b>Signature:</b>
```typescript
migrationVersion?: SavedObjectsMigrationVersion;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectsUpdateOptions](./kibana-plugin-core-public.savedobjectsupdateoptions.md) &gt; [upsert](./kibana-plugin-core-public.savedobjectsupdateoptions.upsert.md)
## SavedObjectsUpdateOptions.upsert property
<b>Signature:</b>
```typescript
upsert?: Attributes;
```

View file

@ -8,7 +8,7 @@
<b>Signature:</b>
```typescript
export interface SavedObjectsBulkUpdateObject<T = unknown> extends Pick<SavedObjectsUpdateOptions, 'version' | 'references'>
export interface SavedObjectsBulkUpdateObject<T = unknown> extends Pick<SavedObjectsUpdateOptions<T>, 'version' | 'references'>
```
## Properties

View file

@ -9,7 +9,7 @@ Updates an SavedObject
<b>Signature:</b>
```typescript
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions<T>): Promise<SavedObjectsUpdateResponse<T>>;
```
## Parameters
@ -19,7 +19,7 @@ update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?:
| type | <code>string</code> | |
| id | <code>string</code> | |
| attributes | <code>Partial&lt;T&gt;</code> | |
| options | <code>SavedObjectsUpdateOptions</code> | |
| options | <code>SavedObjectsUpdateOptions&lt;T&gt;</code> | |
<b>Returns:</b>

View file

@ -9,7 +9,7 @@ Updates an object
<b>Signature:</b>
```typescript
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions<T>): Promise<SavedObjectsUpdateResponse<T>>;
```
## Parameters
@ -19,7 +19,7 @@ update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?:
| type | <code>string</code> | |
| id | <code>string</code> | |
| attributes | <code>Partial&lt;T&gt;</code> | |
| options | <code>SavedObjectsUpdateOptions</code> | |
| options | <code>SavedObjectsUpdateOptions&lt;T&gt;</code> | |
<b>Returns:</b>

View file

@ -8,7 +8,7 @@
<b>Signature:</b>
```typescript
export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions
export interface SavedObjectsUpdateOptions<Attributes = unknown> extends SavedObjectsBaseOptions
```
## Properties
@ -17,5 +17,6 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions
| --- | --- | --- |
| [references](./kibana-plugin-core-server.savedobjectsupdateoptions.references.md) | <code>SavedObjectReference[]</code> | A reference to another saved object. |
| [refresh](./kibana-plugin-core-server.savedobjectsupdateoptions.refresh.md) | <code>MutatingOperationRefreshSetting</code> | The Elasticsearch Refresh setting for this operation |
| [upsert](./kibana-plugin-core-server.savedobjectsupdateoptions.upsert.md) | <code>Attributes</code> | If specified, will be used to perform an upsert if the document doesn't exist |
| [version](./kibana-plugin-core-server.savedobjectsupdateoptions.version.md) | <code>string</code> | An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsUpdateOptions](./kibana-plugin-core-server.savedobjectsupdateoptions.md) &gt; [upsert](./kibana-plugin-core-server.savedobjectsupdateoptions.upsert.md)
## SavedObjectsUpdateOptions.upsert property
If specified, will be used to perform an upsert if the document doesn't exist
<b>Signature:</b>
```typescript
upsert?: Attributes;
```

View file

@ -1226,7 +1226,7 @@ export class SavedObjectsClient {
// Warning: (ae-forgotten-export) The symbol "SavedObjectsFindOptions" needs to be exported by the entry point index.d.ts
find: <T = unknown, A = unknown>(options: SavedObjectsFindOptions_2) => Promise<SavedObjectsFindResponsePublic<T, unknown>>;
get: <T = unknown>(type: string, id: string) => Promise<SimpleSavedObject<T>>;
update<T = unknown>(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise<SimpleSavedObject<T>>;
update<T = unknown>(type: string, id: string, attributes: T, { version, references, upsert }?: SavedObjectsUpdateOptions): Promise<SimpleSavedObject<T>>;
}
// @public
@ -1447,11 +1447,12 @@ export interface SavedObjectsStart {
}
// @public (undocumented)
export interface SavedObjectsUpdateOptions {
migrationVersion?: SavedObjectsMigrationVersion;
export interface SavedObjectsUpdateOptions<Attributes = unknown> {
// (undocumented)
references?: SavedObjectReference[];
// (undocumented)
upsert?: Attributes;
// (undocumented)
version?: string;
}

View file

@ -223,6 +223,26 @@ describe('SavedObjectsClient', () => {
`);
});
test('handles the `upsert` option', () => {
savedObjectsClient.update('index-pattern', 'logstash-*', attributes, {
upsert: {
hello: 'dolly',
},
});
expect(http.fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/saved_objects/index-pattern/logstash-*",
Object {
"body": "{\\"attributes\\":{\\"foo\\":\\"Foo\\",\\"bar\\":\\"Bar\\"},\\"upsert\\":{\\"hello\\":\\"dolly\\"}}",
"method": "PUT",
"query": undefined,
},
],
]
`);
});
test('rejects when HTTP call fails', async () => {
http.fetch.mockRejectedValueOnce(new Error('Request failed'));
await expect(

View file

@ -77,10 +77,9 @@ export interface SavedObjectsBulkUpdateOptions {
}
/** @public */
export interface SavedObjectsUpdateOptions {
export interface SavedObjectsUpdateOptions<Attributes = unknown> {
version?: string;
/** {@inheritDoc SavedObjectsMigrationVersion} */
migrationVersion?: SavedObjectsMigrationVersion;
upsert?: Attributes;
references?: SavedObjectReference[];
}
@ -437,7 +436,7 @@ export class SavedObjectsClient {
type: string,
id: string,
attributes: T,
{ version, migrationVersion, references }: SavedObjectsUpdateOptions = {}
{ version, references, upsert }: SavedObjectsUpdateOptions = {}
): Promise<SimpleSavedObject<T>> {
if (!type || !id || !attributes) {
return Promise.reject(new Error('requires type, id and attributes'));
@ -446,9 +445,9 @@ export class SavedObjectsClient {
const path = this.getPath([type, id]);
const body = {
attributes,
migrationVersion,
references,
version,
upsert,
};
return this.savedObjectsFetch(path, {

View file

@ -71,7 +71,6 @@ export class SimpleSavedObject<T = unknown> {
public save(): Promise<SimpleSavedObject<T>> {
if (this.id) {
return this.client.update(this.type, this.id, this.attributes, {
migrationVersion: this.migrationVersion,
references: this.references,
});
} else {

View file

@ -9,6 +9,7 @@
import { schema } from '@kbn/config-schema';
import { IRouter } from '../../http';
import { CoreUsageDataSetup } from '../../core_usage_data';
import type { SavedObjectsUpdateOptions } from '../service/saved_objects_client';
import { catchAndReturnBoomErrors } from './utils';
interface RouteDependencies {
@ -36,13 +37,14 @@ export const registerUpdateRoute = (router: IRouter, { coreUsageData }: RouteDep
})
)
),
upsert: schema.maybe(schema.recordOf(schema.string(), schema.any())),
}),
},
},
catchAndReturnBoomErrors(async (context, req, res) => {
const { type, id } = req.params;
const { attributes, version, references } = req.body;
const options = { version, references };
const { attributes, version, references, upsert } = req.body;
const options: SavedObjectsUpdateOptions = { version, references, upsert };
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsUpdate({ request: req }).catch(() => {});

View file

@ -4326,6 +4326,30 @@ describe('SavedObjectsRepository', () => {
await test([]);
});
it(`uses the 'upsertAttributes' option when specified`, async () => {
await updateSuccess(type, id, attributes, {
upsert: {
title: 'foo',
description: 'bar',
},
});
expect(client.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'index-pattern:logstash-*',
body: expect.objectContaining({
upsert: expect.objectContaining({
type: 'index-pattern',
'index-pattern': {
title: 'foo',
description: 'bar',
},
}),
}),
}),
expect.anything()
);
});
it(`doesn't accept custom references if not an array`, async () => {
const test = async (references) => {
await updateSuccess(type, id, attributes, { references });

View file

@ -1174,13 +1174,13 @@ export class SavedObjectsRepository {
type: string,
id: string,
attributes: Partial<T>,
options: SavedObjectsUpdateOptions = {}
options: SavedObjectsUpdateOptions<T> = {}
): Promise<SavedObjectsUpdateResponse<T>> {
if (!this._allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
const { version, references, refresh = DEFAULT_REFRESH_SETTING } = options;
const { version, references, upsert, refresh = DEFAULT_REFRESH_SETTING } = options;
const namespace = normalizeNamespace(options.namespace);
let preflightResult: SavedObjectsRawDoc | undefined;
@ -1190,6 +1190,30 @@ export class SavedObjectsRepository {
const time = this._getCurrentTime();
let rawUpsert: SavedObjectsRawDoc | undefined;
if (upsert) {
let savedObjectNamespace: string | undefined;
let savedObjectNamespaces: string[] | undefined;
if (this._registry.isSingleNamespace(type) && namespace) {
savedObjectNamespace = namespace;
} else if (this._registry.isMultiNamespace(type)) {
savedObjectNamespaces = await this.preflightGetNamespaces(type, id, namespace);
}
const migrated = this._migrator.migrateDocument({
id,
type,
...(savedObjectNamespace && { namespace: savedObjectNamespace }),
...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }),
attributes: {
...upsert,
},
updated_at: time,
});
rawUpsert = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc);
}
const doc = {
[type]: attributes,
updated_at: time,
@ -1205,6 +1229,7 @@ export class SavedObjectsRepository {
body: {
doc,
...(rawUpsert && { upsert: rawUpsert._source }),
},
_source_includes: ['namespace', 'namespaces', 'originId'],
require_alias: true,

View file

@ -101,7 +101,7 @@ export interface SavedObjectsBulkCreateObject<T = unknown> {
* @public
*/
export interface SavedObjectsBulkUpdateObject<T = unknown>
extends Pick<SavedObjectsUpdateOptions, 'version' | 'references'> {
extends Pick<SavedObjectsUpdateOptions<T>, 'version' | 'references'> {
/** The ID of this Saved Object, guaranteed to be unique for all objects of the same `type` */
id: string;
/** The type of this Saved Object. Each plugin can define it's own custom Saved Object types. */
@ -207,13 +207,15 @@ export interface SavedObjectsCheckConflictsResponse {
*
* @public
*/
export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions {
export interface SavedObjectsUpdateOptions<Attributes = unknown> extends SavedObjectsBaseOptions {
/** An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. */
version?: string;
/** {@inheritdoc SavedObjectReference} */
references?: SavedObjectReference[];
/** The Elasticsearch Refresh setting for this operation */
refresh?: MutatingOperationRefreshSetting;
/** If specified, will be used to perform an upsert if the document doesn't exist */
upsert?: Attributes;
}
/**
@ -529,7 +531,7 @@ export class SavedObjectsClient {
type: string,
id: string,
attributes: Partial<T>,
options: SavedObjectsUpdateOptions = {}
options: SavedObjectsUpdateOptions<T> = {}
): Promise<SavedObjectsUpdateResponse<T>> {
return await this._repository.update(type, id, attributes, options);
}

View file

@ -2233,7 +2233,7 @@ export interface SavedObjectsBulkResponse<T = unknown> {
}
// @public (undocumented)
export interface SavedObjectsBulkUpdateObject<T = unknown> extends Pick<SavedObjectsUpdateOptions, 'version' | 'references'> {
export interface SavedObjectsBulkUpdateObject<T = unknown> extends Pick<SavedObjectsUpdateOptions<T>, 'version' | 'references'> {
attributes: Partial<T>;
id: string;
namespace?: string;
@ -2292,7 +2292,7 @@ export class SavedObjectsClient {
openPointInTimeForType(type: string | string[], options?: SavedObjectsOpenPointInTimeOptions): Promise<SavedObjectsOpenPointInTimeResponse>;
removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise<SavedObjectsRemoveReferencesToResponse>;
resolve<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObjectsResolveResponse<T>>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions<T>): Promise<SavedObjectsUpdateResponse<T>>;
}
// @public
@ -2902,7 +2902,7 @@ export class SavedObjectsRepository {
openPointInTimeForType(type: string | string[], { keepAlive, preference }?: SavedObjectsOpenPointInTimeOptions): Promise<SavedObjectsOpenPointInTimeResponse>;
removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise<SavedObjectsRemoveReferencesToResponse>;
resolve<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<SavedObjectsResolveResponse<T>>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions): Promise<SavedObjectsUpdateResponse<T>>;
update<T = unknown>(type: string, id: string, attributes: Partial<T>, options?: SavedObjectsUpdateOptions<T>): Promise<SavedObjectsUpdateResponse<T>>;
}
// @public
@ -3001,9 +3001,10 @@ export interface SavedObjectsTypeMappingDefinition {
}
// @public (undocumented)
export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions {
export interface SavedObjectsUpdateOptions<Attributes = unknown> extends SavedObjectsBaseOptions {
references?: SavedObjectReference[];
refresh?: MutatingOperationRefreshSetting;
upsert?: Attributes;
version?: string;
}

View file

@ -96,6 +96,52 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.body.references).to.eql([]);
});
it('handles upsert', async () => {
await supertest
.put(`/api/saved_objects/visualization/upserted-viz`)
.send({
attributes: {
title: 'foo',
},
upsert: {
title: 'upserted title',
description: 'upserted description',
},
})
.expect(200);
const { body: upserted } = await supertest
.get(`/api/saved_objects/visualization/upserted-viz`)
.expect(200);
expect(upserted.attributes).to.eql({
title: 'upserted title',
description: 'upserted description',
});
await supertest
.put(`/api/saved_objects/visualization/upserted-viz`)
.send({
attributes: {
title: 'foobar',
},
upsert: {
description: 'new upserted description',
version: 9000,
},
})
.expect(200);
const { body: notUpserted } = await supertest
.get(`/api/saved_objects/visualization/upserted-viz`)
.expect(200);
expect(notUpserted.attributes).to.eql({
title: 'foobar',
description: 'upserted description',
});
});
describe('unknown id', () => {
it('should return a generic 404', async () => {
await supertest

View file

@ -40,7 +40,12 @@ export async function partiallyUpdateAlert(
): Promise<void> {
// ensure we only have the valid attributes excluded from AAD
const attributeUpdates = pick(attributes, AlertAttributesExcludedFromAAD);
const updateOptions: SavedObjectsUpdateOptions = pick(options, 'namespace', 'version', 'refresh');
const updateOptions: SavedObjectsUpdateOptions<RawAlert> = pick(
options,
'namespace',
'version',
'refresh'
);
try {
await savedObjectsClient.update<RawAlert>('alert', id, attributeUpdates, updateOptions);