@twin.org/entity-storage-connector-cosmosdb 0.0.3-next.9 → 0.9.0
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 +1 -1
- package/dist/es/cosmosDbEntityStorageConnector.js +414 -61
- package/dist/es/cosmosDbEntityStorageConnector.js.map +1 -1
- package/dist/es/models/ICosmosDbEntityStorageConnectorConfig.js.map +1 -1
- package/dist/es/models/ICosmosDbEntityStorageConnectorConstructorOptions.js.map +1 -1
- package/dist/types/cosmosDbEntityStorageConnector.d.ts +62 -5
- package/dist/types/models/ICosmosDbEntityStorageConnectorConfig.d.ts +7 -0
- package/dist/types/models/ICosmosDbEntityStorageConnectorConstructorOptions.d.ts +0 -1
- package/docs/changelog.md +617 -59
- package/docs/reference/classes/CosmosDbEntityStorageConnector.md +274 -12
- package/docs/reference/interfaces/ICosmosDbEntityStorageConnectorConfig.md +11 -0
- package/docs/reference/interfaces/ICosmosDbEntityStorageConnectorConstructorOptions.md +0 -6
- package/locales/en.json +17 -3
- package/package.json +10 -11
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ To perform testing of this component it may be necessary to launch a local insta
|
|
|
14
14
|
|
|
15
15
|
```shell
|
|
16
16
|
docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest
|
|
17
|
-
docker run
|
|
17
|
+
docker run -p 18081:8081 --detach --name twin-entity-storage-cosmos mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:vnext-preview
|
|
18
18
|
```
|
|
19
19
|
|
|
20
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, Validation } 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.
|
|
@@ -346,6 +509,13 @@ export class CosmosDbEntityStorageConnector {
|
|
|
346
509
|
let sql = "";
|
|
347
510
|
const contextIds = await ContextIdStore.getContextIds();
|
|
348
511
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
512
|
+
EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
|
|
513
|
+
EntityStorageHelper.validateProperties(this._entitySchema, properties);
|
|
514
|
+
if (!Is.empty(limit)) {
|
|
515
|
+
const validationFailures = [];
|
|
516
|
+
Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
|
|
517
|
+
Validation.asValidationError(CosmosDbEntityStorageConnector.CLASS_NAME, "query", validationFailures);
|
|
518
|
+
}
|
|
349
519
|
try {
|
|
350
520
|
const returnSize = limit ?? CosmosDbEntityStorageConnector._DEFAULT_LIMIT;
|
|
351
521
|
let orderByClause = "";
|
|
@@ -354,15 +524,6 @@ export class CosmosDbEntityStorageConnector {
|
|
|
354
524
|
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "sortSingle");
|
|
355
525
|
}
|
|
356
526
|
for (const sortProperty of sortProperties) {
|
|
357
|
-
const propertySchema = this._entitySchema.properties?.find(e => e.property === sortProperty.property);
|
|
358
|
-
if (!propertySchema ||
|
|
359
|
-
(!propertySchema.isPrimary &&
|
|
360
|
-
!propertySchema.isSecondary &&
|
|
361
|
-
!propertySchema.sortDirection)) {
|
|
362
|
-
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "sortNotIndexed", {
|
|
363
|
-
property: sortProperty.property
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
527
|
const direction = sortProperty.sortDirection === SortDirection.Ascending ? "asc" : "desc";
|
|
367
528
|
orderByClause = `ORDER BY c.${String(sortProperty.property)} ${direction}`;
|
|
368
529
|
}
|
|
@@ -373,9 +534,58 @@ export class CosmosDbEntityStorageConnector {
|
|
|
373
534
|
if (queryClause.length > 0) {
|
|
374
535
|
queryClause = ` AND ${queryClause}`;
|
|
375
536
|
}
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
537
|
+
const selectClause = properties ? properties.map(p => `c.${p}`).join(", ") : "*";
|
|
538
|
+
const baseQuery = `SELECT ${selectClause} FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId${queryClause}`;
|
|
539
|
+
const queryParameters = [
|
|
540
|
+
{
|
|
541
|
+
name: "@partitionId",
|
|
542
|
+
value: partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE
|
|
543
|
+
},
|
|
544
|
+
...Object.keys(attributeValues).map(key => ({ name: `@${key}`, value: attributeValues[key] }))
|
|
545
|
+
];
|
|
546
|
+
const queryPartitionKey = partitionKey ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
547
|
+
// For sorted queries use OFFSET LIMIT — CosmosDB continuation tokens are
|
|
548
|
+
// not reliably returned for ORDER BY queries across all service versions.
|
|
549
|
+
// The cursor is a numeric offset encoded as a string (same as SQL connectors).
|
|
550
|
+
const startIndex = Coerce.number(cursor) ?? 0;
|
|
551
|
+
sql = `${baseQuery} ${orderByClause} OFFSET ${startIndex} LIMIT ${returnSize + 1}`;
|
|
552
|
+
const sortedQuerySpecs = { query: sql, parameters: queryParameters };
|
|
553
|
+
const sortedResponse = await this._container.items
|
|
554
|
+
.query(sortedQuerySpecs, {
|
|
555
|
+
partitionKey: queryPartitionKey,
|
|
556
|
+
maxItemCount: returnSize + 1
|
|
557
|
+
})
|
|
558
|
+
.fetchNext();
|
|
559
|
+
const allItems = sortedResponse.resources;
|
|
560
|
+
const hasMore = allItems.length > returnSize;
|
|
561
|
+
return {
|
|
562
|
+
entities: (hasMore ? allItems.slice(0, returnSize) : allItems).map(i => this.itemToEntity(i)),
|
|
563
|
+
cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "queryFailed", { sql }, err);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Count all the entities which match the conditions.
|
|
572
|
+
* @param conditions The optional conditions to match for the entities.
|
|
573
|
+
* @returns The total count of entities in the storage.
|
|
574
|
+
*/
|
|
575
|
+
async count(conditions) {
|
|
576
|
+
try {
|
|
577
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
578
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
579
|
+
const attributeNames = {};
|
|
580
|
+
const attributeValues = {};
|
|
581
|
+
let queryClause = Is.empty(conditions)
|
|
582
|
+
? ""
|
|
583
|
+
: this.buildQueryParameters("", conditions, attributeNames, attributeValues);
|
|
584
|
+
if (queryClause.length > 0) {
|
|
585
|
+
queryClause = ` AND ${queryClause}`;
|
|
586
|
+
}
|
|
587
|
+
const querySpec = {
|
|
588
|
+
query: `SELECT VALUE COUNT(1) FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId${queryClause}`,
|
|
379
589
|
parameters: [
|
|
380
590
|
{
|
|
381
591
|
name: "@partitionId",
|
|
@@ -384,33 +594,127 @@ export class CosmosDbEntityStorageConnector {
|
|
|
384
594
|
...Object.keys(attributeValues).map(key => ({ name: `@${key}`, value: attributeValues[key] }))
|
|
385
595
|
]
|
|
386
596
|
};
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
continuationToken: cursor
|
|
390
|
-
};
|
|
391
|
-
const feedResponse = await this._container.items.query(querySpecs, feedOptions).fetchNext();
|
|
392
|
-
return {
|
|
393
|
-
entities: feedResponse.resources.map(i => this.itemToEntity(i)),
|
|
394
|
-
cursor: feedResponse.continuationToken
|
|
395
|
-
};
|
|
597
|
+
const { resources } = await this._container.items.query(querySpec).fetchAll();
|
|
598
|
+
return resources[0] ?? 0;
|
|
396
599
|
}
|
|
397
600
|
catch (err) {
|
|
398
|
-
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "
|
|
601
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
|
|
399
602
|
}
|
|
400
603
|
}
|
|
401
604
|
/**
|
|
402
|
-
*
|
|
403
|
-
* @returns
|
|
605
|
+
* Get a unique list of all the context ids from the storage.
|
|
606
|
+
* @returns The list of unique context ids.
|
|
404
607
|
*/
|
|
405
|
-
async
|
|
406
|
-
const
|
|
407
|
-
|
|
608
|
+
async getPartitionContextIds() {
|
|
609
|
+
const partitionContextIds = this._partitionContextIds;
|
|
610
|
+
if (!Is.arrayValue(partitionContextIds)) {
|
|
611
|
+
return [];
|
|
612
|
+
}
|
|
408
613
|
try {
|
|
409
|
-
await this._container.
|
|
410
|
-
|
|
614
|
+
const { resources: partitionIds } = await this._container.items
|
|
615
|
+
.query({
|
|
616
|
+
query: `SELECT DISTINCT VALUE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} FROM c`
|
|
617
|
+
})
|
|
618
|
+
.fetchAll();
|
|
619
|
+
return partitionIds
|
|
620
|
+
.filter(id => Is.stringValue(id))
|
|
621
|
+
.map(id => ContextIdHelper.shortSplit(partitionContextIds, id));
|
|
411
622
|
}
|
|
412
|
-
catch {
|
|
413
|
-
|
|
623
|
+
catch (err) {
|
|
624
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Create the target connector for performing the migration using a temporary container.
|
|
629
|
+
* @param newEntitySchema The name of the new entity schema to create the connector for.
|
|
630
|
+
* @returns Connector for performing the migration.
|
|
631
|
+
*/
|
|
632
|
+
async createTargetConnector(newEntitySchema) {
|
|
633
|
+
const migrationContainerId = `${this._config.containerId}Migration${Date.now()}`;
|
|
634
|
+
return new CosmosDbEntityStorageConnector({
|
|
635
|
+
entitySchema: newEntitySchema,
|
|
636
|
+
config: { ...this._config, containerId: migrationContainerId },
|
|
637
|
+
partitionContextIds: this._partitionContextIds
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Finalize the migration by tearing down the old container and replacing it with the target container.
|
|
642
|
+
* @param targetConnector The target connector to finalize the migration with.
|
|
643
|
+
* @param options The options to control how the migration is finalized.
|
|
644
|
+
* @param loggingComponentType The optional component type to use for logging.
|
|
645
|
+
* @returns The final connector pointing at the original container id.
|
|
646
|
+
*/
|
|
647
|
+
async finalizeMigration(targetConnector, options, loggingComponentType) {
|
|
648
|
+
// There is no rename operation in DynamoDB so we have to create a new table with the original name and copy the data over
|
|
649
|
+
// Teardown the existing table with the original name to free up the name for the new table
|
|
650
|
+
await this.teardown(loggingComponentType);
|
|
651
|
+
// Create a new connector with the original table name but with the new schema
|
|
652
|
+
// and copy the data from the migration table to the new table using batch operations
|
|
653
|
+
const finalConnector = new CosmosDbEntityStorageConnector({
|
|
654
|
+
entitySchema: targetConnector._entitySchemaName,
|
|
655
|
+
config: this._config,
|
|
656
|
+
partitionContextIds: this._partitionContextIds
|
|
657
|
+
});
|
|
658
|
+
if (await finalConnector.bootstrap(loggingComponentType)) {
|
|
659
|
+
// Since there is no rename, we need to copy the data from the migration table to the new table
|
|
660
|
+
const partitions = await targetConnector.getPartitionContextIds();
|
|
661
|
+
const batchSize = options?.batchSize ?? CosmosDbEntityStorageConnector._DEFAULT_LIMIT;
|
|
662
|
+
await this.bulkCopy(targetConnector, finalConnector, partitions, batchSize);
|
|
663
|
+
await targetConnector.teardown(loggingComponentType);
|
|
664
|
+
return finalConnector;
|
|
665
|
+
}
|
|
666
|
+
throw new GeneralError(CosmosDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap");
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Cleanup the migration if a migration fails or needs to be aborted.
|
|
670
|
+
* @param targetConnector The target connector to cleanup.
|
|
671
|
+
* @param options The options to control how the migration is cleaned up.
|
|
672
|
+
* @param loggingComponentType The optional component type to use for logging.
|
|
673
|
+
*/
|
|
674
|
+
async cleanupMigration(targetConnector, options, loggingComponentType) {
|
|
675
|
+
// If something failed the only thing to cleanup is the migration table
|
|
676
|
+
await targetConnector?.teardown?.(loggingComponentType);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Copy all entities from sourceConnector to destConnector, paging through each partition.
|
|
680
|
+
* @param sourceConnector The connector to read entities from.
|
|
681
|
+
* @param destConnector The connector to write entities to.
|
|
682
|
+
* @param partitions The partition list returned by getPartitionContextIds.
|
|
683
|
+
* @param batchSize The number of entities to read per page.
|
|
684
|
+
* @internal
|
|
685
|
+
*/
|
|
686
|
+
async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
|
|
687
|
+
let partitionList;
|
|
688
|
+
if (Is.arrayValue(partitions)) {
|
|
689
|
+
partitionList = partitions;
|
|
690
|
+
}
|
|
691
|
+
else if (Is.arrayValue(sourceConnector._partitionContextIds)) {
|
|
692
|
+
partitionList = [];
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
partitionList = [{}];
|
|
696
|
+
}
|
|
697
|
+
for (let i = 0; i < partitionList.length; i++) {
|
|
698
|
+
const partitionKey = ContextIdHelper.combinedContextKey(partitionList[i], sourceConnector._partitionContextIds) ?? CosmosDbEntityStorageConnector._PARTITION_KEY_VALUE;
|
|
699
|
+
let continuationToken;
|
|
700
|
+
do {
|
|
701
|
+
const feedOptions = { maxItemCount: batchSize, continuationToken };
|
|
702
|
+
const { resources, continuationToken: nextToken } = await sourceConnector._container.items
|
|
703
|
+
.query({
|
|
704
|
+
query: `SELECT * FROM c WHERE c.${CosmosDbEntityStorageConnector._PARTITION_KEY} = @partitionId`,
|
|
705
|
+
parameters: [{ name: "@partitionId", value: partitionKey }]
|
|
706
|
+
}, feedOptions)
|
|
707
|
+
.fetchNext();
|
|
708
|
+
continuationToken = nextToken;
|
|
709
|
+
if (Is.arrayValue(resources)) {
|
|
710
|
+
const operations = resources.map(({ _rid: rid, _self: self, _ts: ts, _etag: etag, _attachments: attachments, ...rest }) => ({
|
|
711
|
+
operationType: BulkOperationType.Upsert,
|
|
712
|
+
partitionKey,
|
|
713
|
+
resourceBody: rest
|
|
714
|
+
}));
|
|
715
|
+
await destConnector._container.items.executeBulkOperations(operations);
|
|
716
|
+
}
|
|
717
|
+
} while (Is.stringValue(continuationToken));
|
|
414
718
|
}
|
|
415
719
|
}
|
|
416
720
|
/**
|
|
@@ -450,7 +754,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
450
754
|
* @param objectPath The prefix to use for the condition.
|
|
451
755
|
* @param comparator The operator to map.
|
|
452
756
|
* @param type The type of the property.
|
|
453
|
-
* @param
|
|
757
|
+
* @param attributeNames The attribute names to use in the query.
|
|
454
758
|
* @returns The comparison expression.
|
|
455
759
|
* @throws GeneralError if the comparison operator is not supported.
|
|
456
760
|
* @internal
|
|
@@ -476,6 +780,10 @@ export class CosmosDbEntityStorageConnector {
|
|
|
476
780
|
}
|
|
477
781
|
else if (Is.array(comparator.value)) {
|
|
478
782
|
const dbValues = comparator.value.map(v => this.propertyToDbValue(v, type));
|
|
783
|
+
if (dbValues.length === 0 && comparator.comparison === ComparisonOperator.In) {
|
|
784
|
+
// CosmosDB rejects `IN ()` — return always-false sentinel (#141).
|
|
785
|
+
return "1=0";
|
|
786
|
+
}
|
|
479
787
|
const arrAttributeNames = [];
|
|
480
788
|
for (let i = 0; i < dbValues.length; i++) {
|
|
481
789
|
const arrAttributeName = `${propName}${i}`;
|
|
@@ -485,7 +793,27 @@ export class CosmosDbEntityStorageConnector {
|
|
|
485
793
|
propName = attributeName;
|
|
486
794
|
attributeName = `(${arrAttributeNames.map(name => `@${name}`).join(", ")})`;
|
|
487
795
|
}
|
|
796
|
+
else if (Is.object(comparator.value) &&
|
|
797
|
+
(comparator.comparison === ComparisonOperator.Equals ||
|
|
798
|
+
comparator.comparison === ComparisonOperator.NotEquals)) {
|
|
799
|
+
// CosmosDB SQL does not support object equality with =; expand to per-property comparisons.
|
|
800
|
+
const op = comparator.comparison === ComparisonOperator.Equals ? "=" : "<>";
|
|
801
|
+
const join = comparator.comparison === ComparisonOperator.Equals ? " AND " : " OR ";
|
|
802
|
+
const clauses = [];
|
|
803
|
+
for (const [key, val] of Object.entries(comparator.value)) {
|
|
804
|
+
const paramName = `${propName}${key}`;
|
|
805
|
+
attributeValues[paramName] = val;
|
|
806
|
+
clauses.push(`c.${attributeName}.${key} ${op} @${paramName}`);
|
|
807
|
+
}
|
|
808
|
+
return clauses.length === 1 ? clauses[0] : `(${clauses.join(join)})`;
|
|
809
|
+
}
|
|
488
810
|
else {
|
|
811
|
+
// Avoid parameter name conflicts by ensuring unique parameter names in the query
|
|
812
|
+
let counter = 1;
|
|
813
|
+
while (propName in attributeValues) {
|
|
814
|
+
propName = `${attributeName.replace(/\./g, "").replace(/@/g, "")}${counter}`;
|
|
815
|
+
counter++;
|
|
816
|
+
}
|
|
489
817
|
attributeValues[propName] = comparator.value;
|
|
490
818
|
}
|
|
491
819
|
const matches = attributeName.split(".").length;
|
|
@@ -514,15 +842,23 @@ export class CosmosDbEntityStorageConnector {
|
|
|
514
842
|
else if (comparator.comparison === ComparisonOperator.LessThanOrEqual) {
|
|
515
843
|
return `c.${attributeName} <= @${propName}`;
|
|
516
844
|
}
|
|
517
|
-
else if (
|
|
845
|
+
else if (Is.object(attributeValues[propName]) &&
|
|
518
846
|
comparator.comparison === ComparisonOperator.Includes) {
|
|
519
|
-
return `
|
|
847
|
+
return `ARRAY_CONTAINS(c.${attributeName}, @${propName}, true)`;
|
|
848
|
+
}
|
|
849
|
+
else if ((type === "array" || type === "object") &&
|
|
850
|
+
comparator.comparison === ComparisonOperator.Includes) {
|
|
851
|
+
return `ARRAY_CONTAINS(c.${attributeName}, @${propName})`;
|
|
852
|
+
}
|
|
853
|
+
else if ((type === "array" || type === "object") &&
|
|
854
|
+
comparator.comparison === ComparisonOperator.NotIncludes) {
|
|
855
|
+
return `NOT ARRAY_CONTAINS(c.${attributeName}, @${propName})`;
|
|
520
856
|
}
|
|
521
857
|
else if (comparator.comparison === ComparisonOperator.Includes) {
|
|
522
|
-
return `
|
|
858
|
+
return `CONTAINS(c.${attributeName}, @${propName})`;
|
|
523
859
|
}
|
|
524
860
|
else if (comparator.comparison === ComparisonOperator.NotIncludes) {
|
|
525
|
-
return `
|
|
861
|
+
return `NOT CONTAINS(c.${attributeName}, @${propName})`;
|
|
526
862
|
}
|
|
527
863
|
else if (comparator.comparison === ComparisonOperator.In) {
|
|
528
864
|
return `c.${propName} IN ${attributeName}`;
|
|
@@ -550,7 +886,7 @@ export class CosmosDbEntityStorageConnector {
|
|
|
550
886
|
return `${Coerce.string(value)}`;
|
|
551
887
|
}
|
|
552
888
|
else if (type === "integer" || type === "number") {
|
|
553
|
-
return Coerce.
|
|
889
|
+
return Coerce.number(value) ?? 0;
|
|
554
890
|
}
|
|
555
891
|
else if (type === "boolean") {
|
|
556
892
|
return Coerce.boolean(value) ?? false;
|
|
@@ -597,6 +933,8 @@ export class CosmosDbEntityStorageConnector {
|
|
|
597
933
|
/**
|
|
598
934
|
* Verify the conditions for the entity.
|
|
599
935
|
* @param conditions The conditions to verify.
|
|
936
|
+
* @param obj The object to verify the conditions against.
|
|
937
|
+
* @returns True if all conditions are met, false otherwise.
|
|
600
938
|
* @internal
|
|
601
939
|
*/
|
|
602
940
|
verifyConditions(conditions, obj) {
|
|
@@ -609,13 +947,14 @@ export class CosmosDbEntityStorageConnector {
|
|
|
609
947
|
* @internal
|
|
610
948
|
*/
|
|
611
949
|
itemToEntity(item) {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
950
|
+
return EntityStorageHelper.unPrepareEntity(item, [
|
|
951
|
+
CosmosDbEntityStorageConnector._PARTITION_KEY,
|
|
952
|
+
"_attachments",
|
|
953
|
+
"_etag",
|
|
954
|
+
"_rid",
|
|
955
|
+
"_self",
|
|
956
|
+
"_ts"
|
|
957
|
+
]);
|
|
619
958
|
}
|
|
620
959
|
/**
|
|
621
960
|
* Check if the database exists.
|
|
@@ -676,5 +1015,19 @@ export class CosmosDbEntityStorageConnector {
|
|
|
676
1015
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
677
1016
|
}
|
|
678
1017
|
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Wait for a container to not exist.
|
|
1020
|
+
* @returns Nothing.
|
|
1021
|
+
* @internal
|
|
1022
|
+
*/
|
|
1023
|
+
async waitForContainerNotExists() {
|
|
1024
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
1025
|
+
const containerExists = await this.containerExists();
|
|
1026
|
+
if (!containerExists) {
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
679
1032
|
}
|
|
680
1033
|
//# sourceMappingURL=cosmosDbEntityStorageConnector.js.map
|