@twin.org/entity-storage-connector-cosmosdb 0.0.3-next.2 → 0.0.3-next.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -18
- package/dist/es/cosmosDbEntityStorageConnector.js +397 -39
- package/dist/es/cosmosDbEntityStorageConnector.js.map +1 -1
- package/dist/es/models/ICosmosDbEntityStorageConnectorConfig.js.map +1 -1
- package/dist/types/cosmosDbEntityStorageConnector.d.ts +62 -5
- package/dist/types/models/ICosmosDbEntityStorageConnectorConfig.d.ts +7 -0
- package/docs/changelog.md +412 -47
- package/docs/examples.md +96 -1
- package/docs/reference/classes/CosmosDbEntityStorageConnector.md +282 -20
- package/docs/reference/interfaces/ICosmosDbEntityStorageConnectorConfig.md +17 -6
- package/docs/reference/interfaces/ICosmosDbEntityStorageConnectorConstructorOptions.md +6 -6
- package/locales/en.json +17 -2
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Entity Storage Connector Cosmos DB
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This package provides an Azure Cosmos DB backend for globally distributed persistence across regions. It is designed to work with the wider storage ecosystem so applications can keep behaviour consistent across connectors and environments.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -8,24 +8,13 @@ Entity Storage connector implementation using Cosmos DB storage.
|
|
|
8
8
|
npm install @twin.org/entity-storage-connector-cosmosdb
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Docker
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
To perform testing of this component it may be necessary to launch a local instance to communicate with.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
docker run -d --platform=linux/amd64 -p 8081:8081 -m 3g --name twin-entity-storage-cosmos -e AZURE_COSMOS_EMULATOR_PARTITION_COUNT=10 -e AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true -e AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE='127.0.0.1' mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
Or
|
|
22
|
-
|
|
23
|
-
To install and run the Azure CosmosDB Emulator [Azure CosmosDB Emulator](https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-develop-emulator)
|
|
24
|
-
|
|
25
|
-
Afterwards you can run the tests as follows:
|
|
26
|
-
|
|
27
|
-
```sh
|
|
28
|
-
npm run test
|
|
15
|
+
```shell
|
|
16
|
+
docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest
|
|
17
|
+
docker run -p 18081:8081 --detach --name twin-entity-storage-cosmos mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
|
|
29
18
|
```
|
|
30
19
|
|
|
31
20
|
## Examples
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Copyright 2024 IOTA Stiftung.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
-
import {
|
|
3
|
+
import { BulkOperationType, CosmosClient, PartitionKeyKind } from "@azure/cosmos";
|
|
4
4
|
import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
|
|
5
|
-
import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
|
|
5
|
+
import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, HealthStatus, Is, ObjectHelper } from "@twin.org/core";
|
|
6
6
|
import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, LogicalOperator, SortDirection } from "@twin.org/entity";
|
|
7
|
+
import { EntityStorageHelper } from "@twin.org/entity-storage-models";
|
|
7
8
|
/**
|
|
8
9
|
* Class for performing entity storage operations using Cosmos DB.
|
|
9
10
|
*/
|
|
@@ -27,6 +28,11 @@ export class CosmosDbEntityStorageConnector {
|
|
|
27
28
|
* @internal
|
|
28
29
|
*/
|
|
29
30
|
static _PARTITION_KEY_VALUE = "root";
|
|
31
|
+
/**
|
|
32
|
+
* The name for the schema.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
_entitySchemaName;
|
|
30
36
|
/**
|
|
31
37
|
* The schema for the entity.
|
|
32
38
|
* @internal
|
|
@@ -70,13 +76,16 @@ export class CosmosDbEntityStorageConnector {
|
|
|
70
76
|
Guards.stringValue(CosmosDbEntityStorageConnector.CLASS_NAME, "options.config.databaseId", options.config.databaseId);
|
|
71
77
|
Guards.stringValue(CosmosDbEntityStorageConnector.CLASS_NAME, "options.config.containerId", options.config.containerId);
|
|
72
78
|
this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
|
|
79
|
+
this._entitySchemaName = options.entitySchema;
|
|
73
80
|
this._partitionContextIds = options.partitionContextIds;
|
|
74
81
|
this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
|
|
75
82
|
this._config = options.config;
|
|
76
83
|
this._client = new CosmosClient({
|
|
77
84
|
endpoint: this._config.endpoint,
|
|
78
85
|
key: this._config.key,
|
|
79
|
-
|
|
86
|
+
connectionPolicy: {
|
|
87
|
+
enableEndpointDiscovery: !this._config.disableEndpointDiscovery
|
|
88
|
+
}
|
|
80
89
|
});
|
|
81
90
|
this._container = this._client
|
|
82
91
|
.database(this._config.databaseId)
|
|
@@ -188,6 +197,37 @@ export class CosmosDbEntityStorageConnector {
|
|
|
188
197
|
className() {
|
|
189
198
|
return CosmosDbEntityStorageConnector.CLASS_NAME;
|
|
190
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Returns the health status of the component.
|
|
202
|
+
* @returns The health status of the component.
|
|
203
|
+
*/
|
|
204
|
+
async health() {
|
|
205
|
+
try {
|
|
206
|
+
await this._client
|
|
207
|
+
.database(this._config.databaseId)
|
|
208
|
+
.container(this._config.containerId)
|
|
209
|
+
.read();
|
|
210
|
+
return [
|
|
211
|
+
{
|
|
212
|
+
source: CosmosDbEntityStorageConnector.CLASS_NAME,
|
|
213
|
+
status: HealthStatus.Ok,
|
|
214
|
+
description: "healthDescription",
|
|
215
|
+
data: { databaseId: this._config.databaseId, containerId: this._config.containerId }
|
|
216
|
+
}
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return [
|
|
221
|
+
{
|
|
222
|
+
source: CosmosDbEntityStorageConnector.CLASS_NAME,
|
|
223
|
+
status: HealthStatus.Error,
|
|
224
|
+
description: "healthDescription",
|
|
225
|
+
message: "connectionFailed",
|
|
226
|
+
data: { databaseId: this._config.databaseId, containerId: this._config.containerId }
|
|
227
|
+
}
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
191
231
|
/**
|
|
192
232
|
* Get the schema for the entities.
|
|
193
233
|
* @returns The schema for the entities.
|
|
@@ -214,9 +254,14 @@ export class CosmosDbEntityStorageConnector {
|
|
|
214
254
|
.read();
|
|
215
255
|
return this.itemToEntity(item);
|
|
216
256
|
}
|
|
257
|
+
const conditionValues = [];
|
|
217
258
|
const whereQuery = [
|
|
218
259
|
`c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionKey`
|
|
219
260
|
];
|
|
261
|
+
conditionValues.push({
|
|
262
|
+
name: "@partitionKey",
|
|
263
|
+
value: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
264
|
+
});
|
|
220
265
|
// With a secondary index
|
|
221
266
|
if (Is.stringValue(secondaryIndex)) {
|
|
222
267
|
const secIndex = secondaryIndex.toString();
|
|
@@ -225,24 +270,20 @@ export class CosmosDbEntityStorageConnector {
|
|
|
225
270
|
else {
|
|
226
271
|
whereQuery.push(`c.${this._primaryKey.property} = @id`);
|
|
227
272
|
}
|
|
273
|
+
conditionValues.push({ name: "@id", value: id });
|
|
228
274
|
// With conditions
|
|
229
275
|
if (Is.arrayValue(conditions)) {
|
|
230
276
|
for (const c of conditions) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
277
|
+
whereQuery.push(`c.${c.property} = @${c.property}`);
|
|
278
|
+
conditionValues.push({
|
|
279
|
+
name: `@${c.property}`,
|
|
280
|
+
value: c.value
|
|
281
|
+
});
|
|
235
282
|
}
|
|
236
283
|
}
|
|
237
284
|
const query = {
|
|
238
285
|
query: `SELECT * FROM c WHERE ${whereQuery.join(" AND ")}`,
|
|
239
|
-
parameters:
|
|
240
|
-
{ name: "@id", value: id },
|
|
241
|
-
{
|
|
242
|
-
name: "@partitionKey",
|
|
243
|
-
value: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
244
|
-
}
|
|
245
|
-
]
|
|
286
|
+
parameters: conditionValues
|
|
246
287
|
};
|
|
247
288
|
const { resources: items } = await this._container.items.query(query).fetchAll();
|
|
248
289
|
if (items.length === 1) {
|
|
@@ -271,8 +312,10 @@ export class CosmosDbEntityStorageConnector {
|
|
|
271
312
|
Guards.object(CosmosDbEntityStorageConnector.CLASS_NAME, "entity", entity);
|
|
272
313
|
const contextIds = await ContextIdStore.getContextIds();
|
|
273
314
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
274
|
-
|
|
275
|
-
|
|
315
|
+
const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
|
|
316
|
+
nullBehavior: "omit"
|
|
317
|
+
});
|
|
318
|
+
const id = prepared[this._primaryKey.property];
|
|
276
319
|
try {
|
|
277
320
|
if (Is.arrayValue(conditions)) {
|
|
278
321
|
const item = this._container.item(id, partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE);
|
|
@@ -284,7 +327,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
284
327
|
await this._container.items.upsert({
|
|
285
328
|
id,
|
|
286
329
|
[CosmosDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE,
|
|
287
|
-
...
|
|
330
|
+
...prepared
|
|
288
331
|
});
|
|
289
332
|
}
|
|
290
333
|
catch (err) {
|
|
@@ -301,6 +344,66 @@ export class CosmosDbEntityStorageConnector {
|
|
|
301
344
|
}, err);
|
|
302
345
|
}
|
|
303
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Set multiple entities in a batch.
|
|
349
|
+
* @param entities The entities to set.
|
|
350
|
+
* @returns Nothing.
|
|
351
|
+
*/
|
|
352
|
+
async setBatch(entities) {
|
|
353
|
+
Guards.arrayValue(CosmosDbEntityStorageConnector.CLASS_NAME, "entities", entities);
|
|
354
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
355
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
356
|
+
const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
|
|
357
|
+
nullBehavior: "omit"
|
|
358
|
+
}));
|
|
359
|
+
try {
|
|
360
|
+
await this._container.items.executeBulkOperations(preparedEntities.map(prepared => ({
|
|
361
|
+
operationType: BulkOperationType.Upsert,
|
|
362
|
+
partitionKey: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE,
|
|
363
|
+
resourceBody: {
|
|
364
|
+
id: prepared[this._primaryKey.property],
|
|
365
|
+
[CosmosDbEntityStorageConnector._PARTITION_KEY]: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE,
|
|
366
|
+
...prepared
|
|
367
|
+
}
|
|
368
|
+
})));
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Empty all entities from the storage.
|
|
376
|
+
* @returns Nothing.
|
|
377
|
+
*/
|
|
378
|
+
async empty() {
|
|
379
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
380
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
381
|
+
const pk = partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
382
|
+
try {
|
|
383
|
+
let continuationToken;
|
|
384
|
+
do {
|
|
385
|
+
const feedOptions = { maxItemCount: 100, continuationToken };
|
|
386
|
+
const { resources, continuationToken: nextToken } = await this._container.items
|
|
387
|
+
.query({
|
|
388
|
+
query: `SELECT c.id FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @pk`,
|
|
389
|
+
parameters: [{ name: "@pk", value: pk }]
|
|
390
|
+
}, feedOptions)
|
|
391
|
+
.fetchNext();
|
|
392
|
+
continuationToken = nextToken;
|
|
393
|
+
if (Is.arrayValue(resources)) {
|
|
394
|
+
const operations = resources.map(r => ({
|
|
395
|
+
operationType: BulkOperationType.Delete,
|
|
396
|
+
id: r.id,
|
|
397
|
+
partitionKey: pk
|
|
398
|
+
}));
|
|
399
|
+
await this._container.items.executeBulkOperations(operations);
|
|
400
|
+
}
|
|
401
|
+
} while (Is.stringValue(continuationToken));
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
304
407
|
/**
|
|
305
408
|
* Remove the entity.
|
|
306
409
|
* @param id The id of the entity to remove.
|
|
@@ -332,6 +435,66 @@ export class CosmosDbEntityStorageConnector {
|
|
|
332
435
|
}, err);
|
|
333
436
|
}
|
|
334
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Remove multiple entities by id.
|
|
440
|
+
* @param ids The ids of the entities to remove.
|
|
441
|
+
* @returns Nothing.
|
|
442
|
+
*/
|
|
443
|
+
async removeBatch(ids) {
|
|
444
|
+
Guards.arrayValue(CosmosDbEntityStorageConnector.CLASS_NAME, "ids", ids);
|
|
445
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
446
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
447
|
+
try {
|
|
448
|
+
const operations = ids.map(id => ({
|
|
449
|
+
operationType: BulkOperationType.Delete,
|
|
450
|
+
id,
|
|
451
|
+
partitionKey: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
452
|
+
}));
|
|
453
|
+
await this._container.items.executeBulkOperations(operations);
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Teardown the storage by deleting the underlying container.
|
|
461
|
+
* @param nodeLoggingComponentType The node logging component type.
|
|
462
|
+
* @returns True if the teardown process was successful.
|
|
463
|
+
*/
|
|
464
|
+
async teardown(nodeLoggingComponentType) {
|
|
465
|
+
const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
|
|
466
|
+
await nodeLogging?.log({
|
|
467
|
+
level: "info",
|
|
468
|
+
source: CosmosDbEntityStorageConnector.CLASS_NAME,
|
|
469
|
+
ts: Date.now(),
|
|
470
|
+
message: "containerDeleting",
|
|
471
|
+
data: { containerId: this._config.containerId }
|
|
472
|
+
});
|
|
473
|
+
try {
|
|
474
|
+
if (await this.containerExists()) {
|
|
475
|
+
await this._container.delete();
|
|
476
|
+
await this.waitForContainerNotExists();
|
|
477
|
+
}
|
|
478
|
+
await nodeLogging?.log({
|
|
479
|
+
level: "info",
|
|
480
|
+
source: CosmosDbEntityStorageConnector.CLASS_NAME,
|
|
481
|
+
ts: Date.now(),
|
|
482
|
+
message: "containerDeleted",
|
|
483
|
+
data: { containerId: this._config.containerId }
|
|
484
|
+
});
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
await nodeLogging?.log({
|
|
489
|
+
level: "error",
|
|
490
|
+
source: CosmosDbEntityStorageConnector.CLASS_NAME,
|
|
491
|
+
ts: Date.now(),
|
|
492
|
+
message: "teardownFailed",
|
|
493
|
+
error: BaseError.fromError(err)
|
|
494
|
+
});
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
335
498
|
/**
|
|
336
499
|
* Find all the entities which match the conditions.
|
|
337
500
|
* @param conditions The conditions to match for the entities.
|
|
@@ -373,7 +536,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
373
536
|
if (queryClause.length > 0) {
|
|
374
537
|
queryClause = ` AND ${queryClause}`;
|
|
375
538
|
}
|
|
376
|
-
sql = `SELECT ${properties ? properties.map(p => `c.${p}`).join(", ") : "*"} FROM c WHERE c.
|
|
539
|
+
sql = `SELECT ${properties ? properties.map(p => `c.${p}`).join(", ") : "*"} FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId ${queryClause} ${orderByClause}`;
|
|
377
540
|
const querySpecs = {
|
|
378
541
|
query: sql,
|
|
379
542
|
parameters: [
|
|
@@ -389,9 +552,23 @@ export class CosmosDbEntityStorageConnector {
|
|
|
389
552
|
continuationToken: cursor
|
|
390
553
|
};
|
|
391
554
|
const feedResponse = await this._container.items.query(querySpecs, feedOptions).fetchNext();
|
|
555
|
+
// CosmosDB returns a continuation token even on the last page, so peek ahead
|
|
556
|
+
// to confirm there are actually more results before exposing the cursor.
|
|
557
|
+
let resultCursor;
|
|
558
|
+
if (feedResponse.resources.length >= returnSize && feedResponse.continuationToken) {
|
|
559
|
+
const peekResponse = await this._container.items
|
|
560
|
+
.query(querySpecs, {
|
|
561
|
+
maxItemCount: 1,
|
|
562
|
+
continuationToken: feedResponse.continuationToken
|
|
563
|
+
})
|
|
564
|
+
.fetchNext();
|
|
565
|
+
if (peekResponse.resources.length > 0) {
|
|
566
|
+
resultCursor = feedResponse.continuationToken;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
392
569
|
return {
|
|
393
570
|
entities: feedResponse.resources.map(i => this.itemToEntity(i)),
|
|
394
|
-
cursor:
|
|
571
|
+
cursor: resultCursor
|
|
395
572
|
};
|
|
396
573
|
}
|
|
397
574
|
catch (err) {
|
|
@@ -399,18 +576,153 @@ export class CosmosDbEntityStorageConnector {
|
|
|
399
576
|
}
|
|
400
577
|
}
|
|
401
578
|
/**
|
|
402
|
-
*
|
|
403
|
-
* @
|
|
579
|
+
* Count all the entities which match the conditions.
|
|
580
|
+
* @param conditions The optional conditions to match for the entities.
|
|
581
|
+
* @returns The total count of entities in the storage.
|
|
404
582
|
*/
|
|
405
|
-
async
|
|
406
|
-
const contextIds = await ContextIdStore.getContextIds();
|
|
407
|
-
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
583
|
+
async count(conditions) {
|
|
408
584
|
try {
|
|
409
|
-
await
|
|
410
|
-
|
|
585
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
586
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
587
|
+
const attributeNames = {};
|
|
588
|
+
const attributeValues = {};
|
|
589
|
+
let queryClause = Is.empty(conditions)
|
|
590
|
+
? ""
|
|
591
|
+
: this.buildQueryParameters("", conditions, attributeNames, attributeValues);
|
|
592
|
+
if (queryClause.length > 0) {
|
|
593
|
+
queryClause = ` AND ${queryClause}`;
|
|
594
|
+
}
|
|
595
|
+
const querySpec = {
|
|
596
|
+
query: `SELECT VALUE COUNT(1) FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId${queryClause}`,
|
|
597
|
+
parameters: [
|
|
598
|
+
{
|
|
599
|
+
name: "@partitionId",
|
|
600
|
+
value: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
601
|
+
},
|
|
602
|
+
...Object.keys(attributeValues).map(key => ({ name: `@${key}`, value: attributeValues[key] }))
|
|
603
|
+
]
|
|
604
|
+
};
|
|
605
|
+
const { resources } = await this._container.items.query(querySpec).fetchAll();
|
|
606
|
+
return resources[0] ?? 0;
|
|
411
607
|
}
|
|
412
|
-
catch {
|
|
413
|
-
|
|
608
|
+
catch (err) {
|
|
609
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Get a unique list of all the context ids from the storage.
|
|
614
|
+
* @returns The list of unique context ids.
|
|
615
|
+
*/
|
|
616
|
+
async getPartitionContextIds() {
|
|
617
|
+
const partitionContextIds = this._partitionContextIds;
|
|
618
|
+
if (!Is.arrayValue(partitionContextIds)) {
|
|
619
|
+
return [];
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
const { resources: partitionIds } = await this._container.items
|
|
623
|
+
.query({
|
|
624
|
+
query: `SELECT DISTINCT VALUE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} FROM c`
|
|
625
|
+
})
|
|
626
|
+
.fetchAll();
|
|
627
|
+
return partitionIds
|
|
628
|
+
.filter(id => Is.stringValue(id))
|
|
629
|
+
.map(id => ContextIdHelper.shortSplit(partitionContextIds, id));
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Create the target connector for performing the migration using a temporary container.
|
|
637
|
+
* @param newEntitySchema The name of the new entity schema to create the connector for.
|
|
638
|
+
* @returns Connector for performing the migration.
|
|
639
|
+
*/
|
|
640
|
+
async createTargetConnector(newEntitySchema) {
|
|
641
|
+
const migrationContainerId = `${this._config.containerId}Migration${Date.now()}`;
|
|
642
|
+
return new CosmosDbEntityStorageConnector({
|
|
643
|
+
entitySchema: newEntitySchema,
|
|
644
|
+
config: { ...this._config, containerId: migrationContainerId },
|
|
645
|
+
partitionContextIds: this._partitionContextIds
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Finalize the migration by tearing down the old container and replacing it with the target container.
|
|
650
|
+
* @param targetConnector The target connector to finalize the migration with.
|
|
651
|
+
* @param options The options to control how the migration is finalized.
|
|
652
|
+
* @param loggingComponentType The optional component type to use for logging.
|
|
653
|
+
* @returns The final connector pointing at the original container id.
|
|
654
|
+
*/
|
|
655
|
+
async finalizeMigration(targetConnector, options, loggingComponentType) {
|
|
656
|
+
// There is no rename operation in DynamoDB so we have to create a new table with the original name and copy the data over
|
|
657
|
+
// Teardown the existing table with the original name to free up the name for the new table
|
|
658
|
+
await this.teardown(loggingComponentType);
|
|
659
|
+
// Create a new connector with the original table name but with the new schema
|
|
660
|
+
// and copy the data from the migration table to the new table using batch operations
|
|
661
|
+
const finalConnector = new CosmosDbEntityStorageConnector({
|
|
662
|
+
entitySchema: targetConnector._entitySchemaName,
|
|
663
|
+
config: this._config,
|
|
664
|
+
partitionContextIds: this._partitionContextIds
|
|
665
|
+
});
|
|
666
|
+
if (await finalConnector.bootstrap(loggingComponentType)) {
|
|
667
|
+
// Since there is no rename, we need to copy the data from the migration table to the new table
|
|
668
|
+
const partitions = await targetConnector.getPartitionContextIds();
|
|
669
|
+
const batchSize = options?.batchSize ?? CosmosDbEntityStorageConnector._DEFAULT_LIMIT;
|
|
670
|
+
await this.bulkCopy(targetConnector, finalConnector, partitions, batchSize);
|
|
671
|
+
await targetConnector.teardown(loggingComponentType);
|
|
672
|
+
return finalConnector;
|
|
673
|
+
}
|
|
674
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap");
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Cleanup the migration if a migration fails or needs to be aborted.
|
|
678
|
+
* @param targetConnector The target connector to cleanup.
|
|
679
|
+
* @param options The options to control how the migration is cleaned up.
|
|
680
|
+
* @param loggingComponentType The optional component type to use for logging.
|
|
681
|
+
*/
|
|
682
|
+
async cleanupMigration(targetConnector, options, loggingComponentType) {
|
|
683
|
+
// If something failed the only thing to cleanup is the migration table
|
|
684
|
+
await targetConnector?.teardown?.(loggingComponentType);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Copy all entities from sourceConnector to destConnector, paging through each partition.
|
|
688
|
+
* @param sourceConnector The connector to read entities from.
|
|
689
|
+
* @param destConnector The connector to write entities to.
|
|
690
|
+
* @param partitions The partition list returned by getPartitionContextIds.
|
|
691
|
+
* @param batchSize The number of entities to read per page.
|
|
692
|
+
* @internal
|
|
693
|
+
*/
|
|
694
|
+
async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
|
|
695
|
+
let partitionList;
|
|
696
|
+
if (Is.arrayValue(partitions)) {
|
|
697
|
+
partitionList = partitions;
|
|
698
|
+
}
|
|
699
|
+
else if (Is.arrayValue(sourceConnector._partitionContextIds)) {
|
|
700
|
+
partitionList = [];
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
partitionList = [{}];
|
|
704
|
+
}
|
|
705
|
+
for (let i = 0; i < partitionList.length; i++) {
|
|
706
|
+
const partitionKey = ContextIdHelper.combinedContextKey(partitionList[i], sourceConnector._partitionContextIds) ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
707
|
+
let continuationToken;
|
|
708
|
+
do {
|
|
709
|
+
const feedOptions = { maxItemCount: batchSize, continuationToken };
|
|
710
|
+
const { resources, continuationToken: nextToken } = await sourceConnector._container.items
|
|
711
|
+
.query({
|
|
712
|
+
query: `SELECT * FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId`,
|
|
713
|
+
parameters: [{ name: "@partitionId", value: partitionKey }]
|
|
714
|
+
}, feedOptions)
|
|
715
|
+
.fetchNext();
|
|
716
|
+
continuationToken = nextToken;
|
|
717
|
+
if (Is.arrayValue(resources)) {
|
|
718
|
+
const operations = resources.map(({ _rid: rid, _self: self, _ts: ts, _etag: etag, _attachments: attachments, ...rest }) => ({
|
|
719
|
+
operationType: BulkOperationType.Upsert,
|
|
720
|
+
partitionKey,
|
|
721
|
+
resourceBody: rest
|
|
722
|
+
}));
|
|
723
|
+
await destConnector._container.items.executeBulkOperations(operations);
|
|
724
|
+
}
|
|
725
|
+
} while (Is.stringValue(continuationToken));
|
|
414
726
|
}
|
|
415
727
|
}
|
|
416
728
|
/**
|
|
@@ -463,7 +775,18 @@ export class CosmosDbEntityStorageConnector {
|
|
|
463
775
|
prop += comparator.property;
|
|
464
776
|
let attributeName = this.populateAttributeNames(prop, attributeNames);
|
|
465
777
|
let propName = `${attributeName.replace(/\./g, "").replace(/@/g, "")}`;
|
|
466
|
-
if (
|
|
778
|
+
if ((comparator.comparison === ComparisonOperator.Equals ||
|
|
779
|
+
comparator.comparison === ComparisonOperator.NotEquals) &&
|
|
780
|
+
(comparator.value === null || comparator.value === undefined)) {
|
|
781
|
+
// Cosmos DB SQL null semantics mirror standard SQL: any comparison using = or <>
|
|
782
|
+
// against null evaluates to UNKNOWN, not TRUE, so no rows are returned.
|
|
783
|
+
// IS_NULL() and IS_DEFINED() must be used instead (no bound parameter needed).
|
|
784
|
+
if (comparator.comparison === ComparisonOperator.Equals) {
|
|
785
|
+
return `(IS_NULL(c.${attributeName}) OR NOT IS_DEFINED(c.${attributeName}))`;
|
|
786
|
+
}
|
|
787
|
+
return `(IS_DEFINED(c.${attributeName}) AND NOT IS_NULL(c.${attributeName}))`;
|
|
788
|
+
}
|
|
789
|
+
else if (Is.array(comparator.value)) {
|
|
467
790
|
const dbValues = comparator.value.map(v => this.propertyToDbValue(v, type));
|
|
468
791
|
const arrAttributeNames = [];
|
|
469
792
|
for (let i = 0; i < dbValues.length; i++) {
|
|
@@ -474,7 +797,27 @@ export class CosmosDbEntityStorageConnector {
|
|
|
474
797
|
propName = attributeName;
|
|
475
798
|
attributeName = `(${arrAttributeNames.map(name => `@${name}`).join(", ")})`;
|
|
476
799
|
}
|
|
800
|
+
else if (Is.object(comparator.value) &&
|
|
801
|
+
(comparator.comparison === ComparisonOperator.Equals ||
|
|
802
|
+
comparator.comparison === ComparisonOperator.NotEquals)) {
|
|
803
|
+
// CosmosDB SQL does not support object equality with =; expand to per-property comparisons.
|
|
804
|
+
const op = comparator.comparison === ComparisonOperator.Equals ? "=" : "<>";
|
|
805
|
+
const join = comparator.comparison === ComparisonOperator.Equals ? " AND " : " OR ";
|
|
806
|
+
const clauses = [];
|
|
807
|
+
for (const [key, val] of Object.entries(comparator.value)) {
|
|
808
|
+
const paramName = `${propName}${key}`;
|
|
809
|
+
attributeValues[paramName] = val;
|
|
810
|
+
clauses.push(`c.${attributeName}.${key} ${op} @${paramName}`);
|
|
811
|
+
}
|
|
812
|
+
return clauses.length === 1 ? clauses[0] : `(${clauses.join(join)})`;
|
|
813
|
+
}
|
|
477
814
|
else {
|
|
815
|
+
// Avoid parameter name conflicts by ensuring unique parameter names in the query
|
|
816
|
+
let counter = 1;
|
|
817
|
+
while (propName in attributeValues) {
|
|
818
|
+
propName = `${attributeName.replace(/\./g, "").replace(/@/g, "")}${counter}`;
|
|
819
|
+
counter++;
|
|
820
|
+
}
|
|
478
821
|
attributeValues[propName] = comparator.value;
|
|
479
822
|
}
|
|
480
823
|
const matches = attributeName.split(".").length;
|
|
@@ -505,13 +848,13 @@ export class CosmosDbEntityStorageConnector {
|
|
|
505
848
|
}
|
|
506
849
|
else if (typeof attributeValues[propName] === "object" &&
|
|
507
850
|
comparator.comparison === ComparisonOperator.Includes) {
|
|
508
|
-
return `
|
|
851
|
+
return `ARRAY_CONTAINS(c.${attributeName}, @${propName}, true)`;
|
|
509
852
|
}
|
|
510
853
|
else if (comparator.comparison === ComparisonOperator.Includes) {
|
|
511
|
-
return `
|
|
854
|
+
return `CONTAINS(c.${attributeName}, @${propName})`;
|
|
512
855
|
}
|
|
513
856
|
else if (comparator.comparison === ComparisonOperator.NotIncludes) {
|
|
514
|
-
return `
|
|
857
|
+
return `NOT CONTAINS(c.${attributeName}, @${propName})`;
|
|
515
858
|
}
|
|
516
859
|
else if (comparator.comparison === ComparisonOperator.In) {
|
|
517
860
|
return `c.${propName} IN ${attributeName}`;
|
|
@@ -598,13 +941,14 @@ export class CosmosDbEntityStorageConnector {
|
|
|
598
941
|
* @internal
|
|
599
942
|
*/
|
|
600
943
|
itemToEntity(item) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
944
|
+
return EntityStorageHelper.unPrepareEntity(item, [
|
|
945
|
+
CosmosDbEntityStorageConnector._PARTITION_KEY,
|
|
946
|
+
"_attachments",
|
|
947
|
+
"_etag",
|
|
948
|
+
"_rid",
|
|
949
|
+
"_self",
|
|
950
|
+
"_ts"
|
|
951
|
+
]);
|
|
608
952
|
}
|
|
609
953
|
/**
|
|
610
954
|
* Check if the database exists.
|
|
@@ -665,5 +1009,19 @@ export class CosmosDbEntityStorageConnector {
|
|
|
665
1009
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
666
1010
|
}
|
|
667
1011
|
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Wait for a container to not exist.
|
|
1014
|
+
* @returns Nothing.
|
|
1015
|
+
* @internal
|
|
1016
|
+
*/
|
|
1017
|
+
async waitForContainerNotExists() {
|
|
1018
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
1019
|
+
const containerExists = await this.containerExists();
|
|
1020
|
+
if (!containerExists) {
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
668
1026
|
}
|
|
669
1027
|
//# sourceMappingURL=cosmosDbEntityStorageConnector.js.map
|