@twin.org/entity-storage-connector-gcp-firestore 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/dist/es/firestoreEntityStorageConnector.js +530 -73
- package/dist/es/firestoreEntityStorageConnector.js.map +1 -1
- package/dist/types/firestoreEntityStorageConnector.d.ts +64 -2
- package/docs/changelog.md +613 -63
- package/docs/reference/classes/FirestoreEntityStorageConnector.md +284 -8
- package/locales/en.json +17 -3
- package/package.json +10 -10
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Copyright 2024 IOTA Stiftung.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
-
import { Firestore } from "@google-cloud/firestore";
|
|
3
|
+
import { Filter, Firestore } from "@google-cloud/firestore";
|
|
4
4
|
import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
|
|
5
|
-
import { BaseError, ComponentFactory, Converter, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
|
|
6
|
-
import { ComparisonOperator, EntityConditions, EntitySchemaFactory, EntitySchemaHelper, SortDirection } from "@twin.org/entity";
|
|
5
|
+
import { BaseError, ComponentFactory, Converter, GeneralError, Guards, HealthStatus, Is, ObjectHelper, Validation } from "@twin.org/core";
|
|
6
|
+
import { ComparisonOperator, EntityConditions, 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 Firestore.
|
|
9
10
|
*/
|
|
@@ -17,6 +18,17 @@ export class FirestoreEntityStorageConnector {
|
|
|
17
18
|
* @internal
|
|
18
19
|
*/
|
|
19
20
|
static _DEFAULT_LIMIT = 40;
|
|
21
|
+
/**
|
|
22
|
+
* Separator used between context ID parts in Firestore collection names.
|
|
23
|
+
* Must not be "/" which Firestore interprets as a path separator.
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
static _PARTITION_SEPARATOR = ":";
|
|
27
|
+
/**
|
|
28
|
+
* The name for the schema.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
_entitySchemaName;
|
|
20
32
|
/**
|
|
21
33
|
* The schema for the entity.
|
|
22
34
|
* @internal
|
|
@@ -58,6 +70,7 @@ export class FirestoreEntityStorageConnector {
|
|
|
58
70
|
credentials = ObjectHelper.fromBytes(Converter.base64ToBytes(options.config.credentials));
|
|
59
71
|
}
|
|
60
72
|
this._config = options.config;
|
|
73
|
+
this._entitySchemaName = options.entitySchema;
|
|
61
74
|
this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
|
|
62
75
|
this._partitionContextIds = options.partitionContextIds;
|
|
63
76
|
this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
|
|
@@ -82,6 +95,34 @@ export class FirestoreEntityStorageConnector {
|
|
|
82
95
|
className() {
|
|
83
96
|
return FirestoreEntityStorageConnector.CLASS_NAME;
|
|
84
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Returns the health status of the component.
|
|
100
|
+
* @returns The health status of the component.
|
|
101
|
+
*/
|
|
102
|
+
async health() {
|
|
103
|
+
try {
|
|
104
|
+
await this._firestoreClient.listCollections();
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
source: FirestoreEntityStorageConnector.CLASS_NAME,
|
|
108
|
+
status: HealthStatus.Ok,
|
|
109
|
+
description: "healthDescription",
|
|
110
|
+
data: { projectId: this._config.projectId, collectionName: this._config.collectionName }
|
|
111
|
+
}
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [
|
|
116
|
+
{
|
|
117
|
+
source: FirestoreEntityStorageConnector.CLASS_NAME,
|
|
118
|
+
status: HealthStatus.Error,
|
|
119
|
+
description: "healthDescription",
|
|
120
|
+
message: "connectionFailed",
|
|
121
|
+
data: { projectId: this._config.projectId, collectionName: this._config.collectionName }
|
|
122
|
+
}
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
85
126
|
/**
|
|
86
127
|
* Get the schema for the entities.
|
|
87
128
|
* @returns The schema for the entities.
|
|
@@ -149,14 +190,14 @@ export class FirestoreEntityStorageConnector {
|
|
|
149
190
|
async get(id, secondaryIndex, conditions) {
|
|
150
191
|
Guards.stringValue(FirestoreEntityStorageConnector.CLASS_NAME, "id", id);
|
|
151
192
|
const contextIds = await ContextIdStore.getContextIds();
|
|
152
|
-
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
193
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
|
|
153
194
|
try {
|
|
154
195
|
const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
|
|
155
196
|
if (!Is.arrayValue(conditions)) {
|
|
156
197
|
const docRef = collection.doc(id);
|
|
157
198
|
const doc = await docRef.get();
|
|
158
199
|
if (doc.exists) {
|
|
159
|
-
return doc.data();
|
|
200
|
+
return EntityStorageHelper.unPrepareEntity(doc.data(), []);
|
|
160
201
|
}
|
|
161
202
|
}
|
|
162
203
|
// Use conditions to construct a query
|
|
@@ -175,8 +216,7 @@ export class FirestoreEntityStorageConnector {
|
|
|
175
216
|
}
|
|
176
217
|
const querySnapshot = await query.limit(1).get();
|
|
177
218
|
if (!querySnapshot.empty) {
|
|
178
|
-
|
|
179
|
-
return entity;
|
|
219
|
+
return EntityStorageHelper.unPrepareEntity(querySnapshot.docs[0].data(), []);
|
|
180
220
|
}
|
|
181
221
|
}
|
|
182
222
|
catch (err) {
|
|
@@ -192,20 +232,22 @@ export class FirestoreEntityStorageConnector {
|
|
|
192
232
|
async set(entity, conditions) {
|
|
193
233
|
Guards.object(FirestoreEntityStorageConnector.CLASS_NAME, "entity", entity);
|
|
194
234
|
const contextIds = await ContextIdStore.getContextIds();
|
|
195
|
-
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
196
|
-
|
|
235
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
|
|
236
|
+
const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
|
|
237
|
+
nullBehavior: "nullify"
|
|
238
|
+
});
|
|
197
239
|
try {
|
|
198
|
-
const id =
|
|
240
|
+
const id = prepared[this._primaryKey.property];
|
|
199
241
|
const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
|
|
200
242
|
const docRef = collection.doc(id);
|
|
201
243
|
if (!Is.arrayValue(conditions)) {
|
|
202
|
-
await docRef.set(
|
|
244
|
+
await docRef.set(prepared);
|
|
203
245
|
}
|
|
204
246
|
else {
|
|
205
247
|
await this._firestoreClient.runTransaction(async (transaction) => {
|
|
206
248
|
const docSnapshot = await transaction.get(docRef);
|
|
207
249
|
if (!docSnapshot.exists) {
|
|
208
|
-
transaction.set(docRef,
|
|
250
|
+
transaction.set(docRef, prepared);
|
|
209
251
|
}
|
|
210
252
|
else {
|
|
211
253
|
const data = docSnapshot.data();
|
|
@@ -216,7 +258,7 @@ export class FirestoreEntityStorageConnector {
|
|
|
216
258
|
value: c.value
|
|
217
259
|
}))
|
|
218
260
|
})) {
|
|
219
|
-
transaction.set(docRef,
|
|
261
|
+
transaction.set(docRef, prepared);
|
|
220
262
|
}
|
|
221
263
|
}
|
|
222
264
|
});
|
|
@@ -226,6 +268,60 @@ export class FirestoreEntityStorageConnector {
|
|
|
226
268
|
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "setEntityFailed", { id: entity.id }, err);
|
|
227
269
|
}
|
|
228
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Set multiple entities in a batch.
|
|
273
|
+
* @param entities The entities to set.
|
|
274
|
+
* @returns Nothing.
|
|
275
|
+
*/
|
|
276
|
+
async setBatch(entities) {
|
|
277
|
+
Guards.arrayValue(FirestoreEntityStorageConnector.CLASS_NAME, "entities", entities);
|
|
278
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
279
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
|
|
280
|
+
const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
|
|
281
|
+
nullBehavior: "nullify"
|
|
282
|
+
}));
|
|
283
|
+
try {
|
|
284
|
+
const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
|
|
285
|
+
const chunkSize = FirestoreEntityStorageConnector._DEFAULT_LIMIT;
|
|
286
|
+
for (let i = 0; i < preparedEntities.length; i += chunkSize) {
|
|
287
|
+
const chunk = preparedEntities.slice(i, i + chunkSize);
|
|
288
|
+
const batch = this._firestoreClient.batch();
|
|
289
|
+
for (const entity of chunk) {
|
|
290
|
+
const id = entity[this._primaryKey.property];
|
|
291
|
+
const docRef = collection.doc(id);
|
|
292
|
+
batch.set(docRef, entity);
|
|
293
|
+
}
|
|
294
|
+
await batch.commit();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Empty the storage by deleting all entities in the collection.
|
|
303
|
+
* @returns Nothing.
|
|
304
|
+
*/
|
|
305
|
+
async empty() {
|
|
306
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
307
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
|
|
308
|
+
try {
|
|
309
|
+
const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
|
|
310
|
+
const snapshot = await collection.get();
|
|
311
|
+
const chunkSize = 500;
|
|
312
|
+
for (let i = 0; i < snapshot.docs.length; i += chunkSize) {
|
|
313
|
+
const chunk = snapshot.docs.slice(i, i + chunkSize);
|
|
314
|
+
const batch = this._firestoreClient.batch();
|
|
315
|
+
for (const doc of chunk) {
|
|
316
|
+
batch.delete(doc.ref);
|
|
317
|
+
}
|
|
318
|
+
await batch.commit();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
229
325
|
/**
|
|
230
326
|
* Remove the entity.
|
|
231
327
|
* @param id The id of the entity to remove.
|
|
@@ -235,7 +331,7 @@ export class FirestoreEntityStorageConnector {
|
|
|
235
331
|
async remove(id, conditions) {
|
|
236
332
|
Guards.stringValue(FirestoreEntityStorageConnector.CLASS_NAME, "id", id);
|
|
237
333
|
const contextIds = await ContextIdStore.getContextIds();
|
|
238
|
-
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
334
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
|
|
239
335
|
try {
|
|
240
336
|
const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
|
|
241
337
|
const docRef = collection.doc(id);
|
|
@@ -264,6 +360,146 @@ export class FirestoreEntityStorageConnector {
|
|
|
264
360
|
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "removeEntityFailed", { id }, err);
|
|
265
361
|
}
|
|
266
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* Remove multiple entities by their primary key IDs using a Firestore WriteBatch.
|
|
365
|
+
* @param ids The ids of the entities to remove.
|
|
366
|
+
* @returns Nothing.
|
|
367
|
+
*/
|
|
368
|
+
async removeBatch(ids) {
|
|
369
|
+
Guards.arrayValue(FirestoreEntityStorageConnector.CLASS_NAME, "ids", ids);
|
|
370
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
371
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
|
|
372
|
+
try {
|
|
373
|
+
const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
|
|
374
|
+
const chunkSize = 500;
|
|
375
|
+
for (let i = 0; i < ids.length; i += chunkSize) {
|
|
376
|
+
const chunk = ids.slice(i, i + chunkSize);
|
|
377
|
+
const batch = this._firestoreClient.batch();
|
|
378
|
+
for (const id of chunk) {
|
|
379
|
+
const docRef = collection.doc(id);
|
|
380
|
+
batch.delete(docRef);
|
|
381
|
+
}
|
|
382
|
+
await batch.commit();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Teardown the storage by deleting all documents across all partition collections.
|
|
391
|
+
* @param nodeLoggingComponentType The node logging component type.
|
|
392
|
+
* @returns True if the teardown process was successful.
|
|
393
|
+
*/
|
|
394
|
+
async teardown(nodeLoggingComponentType) {
|
|
395
|
+
const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
|
|
396
|
+
await nodeLogging?.log({
|
|
397
|
+
level: "info",
|
|
398
|
+
source: FirestoreEntityStorageConnector.CLASS_NAME,
|
|
399
|
+
ts: Date.now(),
|
|
400
|
+
message: "storeTearingDown"
|
|
401
|
+
});
|
|
402
|
+
try {
|
|
403
|
+
await this.deleteAllPartitionCollections(this._config.collectionName);
|
|
404
|
+
await nodeLogging?.log({
|
|
405
|
+
level: "info",
|
|
406
|
+
source: FirestoreEntityStorageConnector.CLASS_NAME,
|
|
407
|
+
ts: Date.now(),
|
|
408
|
+
message: "storeTornDown"
|
|
409
|
+
});
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
await nodeLogging?.log({
|
|
414
|
+
level: "error",
|
|
415
|
+
source: FirestoreEntityStorageConnector.CLASS_NAME,
|
|
416
|
+
ts: Date.now(),
|
|
417
|
+
message: "teardownFailed",
|
|
418
|
+
error: BaseError.fromError(err)
|
|
419
|
+
});
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Get a unique list of all the context ids from the storage.
|
|
425
|
+
* @returns The list of unique context ids.
|
|
426
|
+
*/
|
|
427
|
+
async getPartitionContextIds() {
|
|
428
|
+
const partitionContextIds = this._partitionContextIds;
|
|
429
|
+
if (!Is.arrayValue(partitionContextIds)) {
|
|
430
|
+
return [];
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
const prefix = `${this._config.collectionName}_`;
|
|
434
|
+
const collections = await this._firestoreClient.listCollections();
|
|
435
|
+
const result = [];
|
|
436
|
+
for (const col of collections) {
|
|
437
|
+
if (col.id.startsWith(prefix)) {
|
|
438
|
+
const partitionKey = col.id.slice(prefix.length);
|
|
439
|
+
if (Is.stringValue(partitionKey)) {
|
|
440
|
+
result.push(ContextIdHelper.shortSplit(partitionContextIds, partitionKey, FirestoreEntityStorageConnector._PARTITION_SEPARATOR));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Create the target connector for performing the migration using a temporary collection name.
|
|
452
|
+
* @param newEntitySchema The name of the new entity schema to create the connector for.
|
|
453
|
+
* @returns Connector for performing the migration.
|
|
454
|
+
*/
|
|
455
|
+
async createTargetConnector(newEntitySchema) {
|
|
456
|
+
const migrationCollectionName = `${this._config.collectionName}Migration${Date.now()}`;
|
|
457
|
+
return new FirestoreEntityStorageConnector({
|
|
458
|
+
entitySchema: newEntitySchema,
|
|
459
|
+
config: { ...this._config, collectionName: migrationCollectionName },
|
|
460
|
+
partitionContextIds: this._partitionContextIds
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Finalize the migration by tearing down the old collections and replacing them with the target collections.
|
|
465
|
+
* @param targetConnector The target connector to finalize the migration with.
|
|
466
|
+
* @param options The options to control how the migration is finalized.
|
|
467
|
+
* @param loggingComponentType The optional component type to use for logging.
|
|
468
|
+
* @returns The final connector pointing at the original collection name.
|
|
469
|
+
*/
|
|
470
|
+
async finalizeMigration(targetConnector, options, loggingComponentType) {
|
|
471
|
+
// Firestore has no collection-rename operation, so we create fresh collections under
|
|
472
|
+
// the original name, copy all documents from the migration collections, then delete the
|
|
473
|
+
// migration collections.
|
|
474
|
+
// Teardown all existing source collections to free up the original collection name prefix.
|
|
475
|
+
await this.teardown(loggingComponentType);
|
|
476
|
+
// Create a new connector at the original collection name but with the new schema.
|
|
477
|
+
const originalCollectionName = this._config.collectionName;
|
|
478
|
+
const finalConnector = new FirestoreEntityStorageConnector({
|
|
479
|
+
entitySchema: targetConnector._entitySchemaName,
|
|
480
|
+
config: { ...targetConnector._config, collectionName: originalCollectionName },
|
|
481
|
+
partitionContextIds: this._partitionContextIds
|
|
482
|
+
});
|
|
483
|
+
if (await finalConnector.bootstrap(loggingComponentType)) {
|
|
484
|
+
// Since there is no rename, we need to copy the data from the migration table to the new table
|
|
485
|
+
const partitions = await targetConnector.getPartitionContextIds();
|
|
486
|
+
const batchSize = options?.batchSize ?? FirestoreEntityStorageConnector._DEFAULT_LIMIT;
|
|
487
|
+
await this.bulkCopy(targetConnector, finalConnector, partitions, batchSize);
|
|
488
|
+
await targetConnector.teardown(loggingComponentType);
|
|
489
|
+
return finalConnector;
|
|
490
|
+
}
|
|
491
|
+
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Cleanup the migration if a migration fails or needs to be aborted.
|
|
495
|
+
* @param targetConnector The target connector to cleanup.
|
|
496
|
+
* @param options The options to control how the migration is cleaned up.
|
|
497
|
+
* @param loggingComponentType The optional component type to use for logging.
|
|
498
|
+
*/
|
|
499
|
+
async cleanupMigration(targetConnector, options, loggingComponentType) {
|
|
500
|
+
// If something failed the only thing to cleanup is the migration table
|
|
501
|
+
await targetConnector?.teardown?.(loggingComponentType);
|
|
502
|
+
}
|
|
267
503
|
/**
|
|
268
504
|
* Find all the entities which match the conditions.
|
|
269
505
|
* @param conditions The conditions to match for the entities.
|
|
@@ -276,12 +512,64 @@ export class FirestoreEntityStorageConnector {
|
|
|
276
512
|
async query(conditions, sortProperties, properties, cursor, limit) {
|
|
277
513
|
const queryDescription = [];
|
|
278
514
|
const contextIds = await ContextIdStore.getContextIds();
|
|
279
|
-
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
515
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
|
|
516
|
+
const finalLimit = limit ?? FirestoreEntityStorageConnector._DEFAULT_LIMIT;
|
|
517
|
+
EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
|
|
518
|
+
EntityStorageHelper.validateProperties(this._entitySchema, properties);
|
|
519
|
+
if (!Is.empty(limit)) {
|
|
520
|
+
const validationFailures = [];
|
|
521
|
+
Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
|
|
522
|
+
Validation.asValidationError(FirestoreEntityStorageConnector.CLASS_NAME, "query", validationFailures);
|
|
523
|
+
}
|
|
280
524
|
try {
|
|
281
525
|
const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
|
|
526
|
+
// Firestore has no native substring search. When any condition needs in-memory
|
|
527
|
+
// filtering (string Includes / NotIncludes), fetch all matching docs and filter
|
|
528
|
+
// client-side, using an index-based cursor for pagination.
|
|
529
|
+
if (!Is.empty(conditions) && this.needsPostFilter(conditions)) {
|
|
530
|
+
queryDescription.push("InMemoryFilter");
|
|
531
|
+
let baseQuery = collection;
|
|
532
|
+
if (Is.arrayValue(sortProperties)) {
|
|
533
|
+
for (const { property, sortDirection } of sortProperties) {
|
|
534
|
+
baseQuery = baseQuery.orderBy(property, sortDirection === SortDirection.Ascending ? "asc" : "desc");
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const allSnapshot = await baseQuery.get();
|
|
538
|
+
let allEntities = allSnapshot.docs.map((doc) => EntityStorageHelper.unPrepareEntity(doc.data(), []));
|
|
539
|
+
allEntities = allEntities.filter(e => EntityConditions.check(e, conditions));
|
|
540
|
+
let projected;
|
|
541
|
+
if (Is.arrayValue(properties)) {
|
|
542
|
+
projected = allEntities.map(e => {
|
|
543
|
+
const out = {};
|
|
544
|
+
for (const prop of properties) {
|
|
545
|
+
if (prop in e) {
|
|
546
|
+
out[prop] = e[prop];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
projected = allEntities;
|
|
554
|
+
}
|
|
555
|
+
const start = Is.stringValue(cursor) ? Number.parseInt(cursor, 10) : 0;
|
|
556
|
+
const page = projected.slice(start, start + finalLimit);
|
|
557
|
+
const nextCursor = start + finalLimit < projected.length ? String(start + finalLimit) : undefined;
|
|
558
|
+
return { entities: page, cursor: nextCursor };
|
|
559
|
+
}
|
|
560
|
+
if (this.hasEmptyInCondition(conditions)) {
|
|
561
|
+
return { entities: [], cursor: undefined };
|
|
562
|
+
}
|
|
563
|
+
// Prune empty-In leaves from OR branches: the Firestore SDK throws on
|
|
564
|
+
// Filter.where(prop, "in", []) even inside an OR where other branches still match.
|
|
565
|
+
// hasEmptyInCondition above already handles the all-false case, so pruning here
|
|
566
|
+
// is safe — any removed leaf was a no-op branch.
|
|
567
|
+
const effectiveConditions = !Is.empty(conditions)
|
|
568
|
+
? (this.pruneEmptyInConditions(conditions) ?? undefined)
|
|
569
|
+
: conditions;
|
|
282
570
|
let query = collection;
|
|
283
|
-
if (!Is.empty(
|
|
284
|
-
query = this.applyConditions(query,
|
|
571
|
+
if (!Is.empty(effectiveConditions)) {
|
|
572
|
+
query = this.applyConditions(query, effectiveConditions);
|
|
285
573
|
queryDescription.push(`Conditions: ${JSON.stringify(conditions)}`);
|
|
286
574
|
}
|
|
287
575
|
if (Is.arrayValue(sortProperties)) {
|
|
@@ -291,24 +579,30 @@ export class FirestoreEntityStorageConnector {
|
|
|
291
579
|
queryDescription.push(`Sort: ${JSON.stringify(sortProperties)}`);
|
|
292
580
|
}
|
|
293
581
|
if (Is.stringValue(cursor)) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
582
|
+
// Discard cursors from a different partition — startAfter() throws if the
|
|
583
|
+
// snapshot belongs to a different collection than the current query.
|
|
584
|
+
const cursorCollection = cursor.slice(0, cursor.lastIndexOf("/"));
|
|
585
|
+
if (cursorCollection === this.collectionName(partitionKey)) {
|
|
586
|
+
const cursorDoc = await this._firestoreClient.doc(cursor).get();
|
|
587
|
+
if (cursorDoc?.exists) {
|
|
588
|
+
query = query.startAfter(cursorDoc);
|
|
589
|
+
}
|
|
590
|
+
queryDescription.push(`Cursor: ${cursor}`);
|
|
297
591
|
}
|
|
298
|
-
queryDescription.push(`Cursor: ${cursor}`);
|
|
299
592
|
}
|
|
300
|
-
|
|
301
|
-
query = query.limit(finalLimit);
|
|
593
|
+
query = query.limit(finalLimit + 1);
|
|
302
594
|
queryDescription.push(`Limit: ${finalLimit}`);
|
|
303
|
-
if (properties) {
|
|
595
|
+
if (Is.arrayValue(properties)) {
|
|
304
596
|
query = query.select(...properties);
|
|
305
597
|
queryDescription.push(`Properties: ${properties.join(", ")}`);
|
|
306
598
|
}
|
|
307
599
|
const querySnapshot = await query.get();
|
|
308
|
-
const
|
|
600
|
+
const hasMore = querySnapshot.docs.length > finalLimit;
|
|
601
|
+
const resultDocs = hasMore ? querySnapshot.docs.slice(0, finalLimit) : querySnapshot.docs;
|
|
602
|
+
const entities = resultDocs.map((doc) => EntityStorageHelper.unPrepareEntity(doc.data(), []));
|
|
309
603
|
let nextCursor;
|
|
310
|
-
if (
|
|
311
|
-
nextCursor =
|
|
604
|
+
if (hasMore) {
|
|
605
|
+
nextCursor = resultDocs[resultDocs.length - 1].ref.path;
|
|
312
606
|
}
|
|
313
607
|
return {
|
|
314
608
|
entities,
|
|
@@ -320,86 +614,249 @@ export class FirestoreEntityStorageConnector {
|
|
|
320
614
|
}
|
|
321
615
|
}
|
|
322
616
|
/**
|
|
323
|
-
*
|
|
324
|
-
* @
|
|
325
|
-
* @
|
|
617
|
+
* Count all the entities which match the conditions.
|
|
618
|
+
* @param conditions The optional conditions to match for the entities.
|
|
619
|
+
* @returns The total count of entities in the storage.
|
|
326
620
|
*/
|
|
327
|
-
async
|
|
328
|
-
const collection = this._firestoreClient.collection(this.collectionName());
|
|
329
|
-
const batchSize = 500;
|
|
330
|
-
const query = collection.limit(batchSize);
|
|
621
|
+
async count(conditions) {
|
|
331
622
|
try {
|
|
332
|
-
await
|
|
623
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
624
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
|
|
625
|
+
const collection = this._firestoreClient.collection(this.collectionName(partitionKey));
|
|
626
|
+
if (this.hasEmptyInCondition(conditions)) {
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
629
|
+
if (!Is.empty(conditions) && this.needsPostFilter(conditions)) {
|
|
630
|
+
const allSnapshot = await collection.get();
|
|
631
|
+
const allEntities = allSnapshot.docs.map((doc) => EntityStorageHelper.unPrepareEntity(doc.data(), []));
|
|
632
|
+
return allEntities.filter(e => EntityConditions.check(e, conditions)).length;
|
|
633
|
+
}
|
|
634
|
+
const effectiveConditions = !Is.empty(conditions)
|
|
635
|
+
? (this.pruneEmptyInConditions(conditions) ?? undefined)
|
|
636
|
+
: conditions;
|
|
637
|
+
let query = collection;
|
|
638
|
+
if (!Is.empty(effectiveConditions)) {
|
|
639
|
+
query = this.applyConditions(query, effectiveConditions);
|
|
640
|
+
}
|
|
641
|
+
const snapshot = await query.count().get();
|
|
642
|
+
return snapshot.data().count;
|
|
333
643
|
}
|
|
334
|
-
catch (
|
|
335
|
-
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "
|
|
644
|
+
catch (err) {
|
|
645
|
+
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Copy all entities from sourceConnector to destConnector, paging through each partition.
|
|
650
|
+
* @param sourceConnector The connector to read entities from.
|
|
651
|
+
* @param destConnector The connector to write entities to.
|
|
652
|
+
* @param partitions The partition list returned by getPartitionContextIds.
|
|
653
|
+
* @param batchSize The number of entities to read per page.
|
|
654
|
+
* @internal
|
|
655
|
+
*/
|
|
656
|
+
async bulkCopy(sourceConnector, destConnector, partitions, batchSize) {
|
|
657
|
+
let partitionList;
|
|
658
|
+
if (Is.arrayValue(partitions)) {
|
|
659
|
+
partitionList = partitions;
|
|
660
|
+
}
|
|
661
|
+
else if (Is.arrayValue(sourceConnector._partitionContextIds)) {
|
|
662
|
+
partitionList = [];
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
partitionList = [{}];
|
|
666
|
+
}
|
|
667
|
+
for (let i = 0; i < partitionList.length; i++) {
|
|
668
|
+
const partitionKey = ContextIdHelper.combinedContextKey(partitionList[i], sourceConnector._partitionContextIds, FirestoreEntityStorageConnector._PARTITION_SEPARATOR);
|
|
669
|
+
const sourceCollection = sourceConnector._firestoreClient.collection(sourceConnector.collectionName(partitionKey));
|
|
670
|
+
const destCollection = destConnector._firestoreClient.collection(destConnector.collectionName(partitionKey));
|
|
671
|
+
let lastDoc;
|
|
672
|
+
let hasMore = true;
|
|
673
|
+
while (hasMore) {
|
|
674
|
+
let pageQuery = sourceCollection.limit(batchSize);
|
|
675
|
+
if (lastDoc) {
|
|
676
|
+
pageQuery = pageQuery.startAfter(lastDoc);
|
|
677
|
+
}
|
|
678
|
+
const snapshot = await pageQuery.get();
|
|
679
|
+
const docs = snapshot.docs;
|
|
680
|
+
for (let j = 0; j < docs.length; j += batchSize) {
|
|
681
|
+
const chunk = docs.slice(j, j + batchSize);
|
|
682
|
+
const batch = destConnector._firestoreClient.batch();
|
|
683
|
+
for (const doc of chunk) {
|
|
684
|
+
batch.set(destCollection.doc(doc.id), doc.data());
|
|
685
|
+
}
|
|
686
|
+
await batch.commit();
|
|
687
|
+
}
|
|
688
|
+
lastDoc = docs[docs.length - 1];
|
|
689
|
+
hasMore = docs.length === batchSize;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Delete all documents in every collection whose name starts with collectionName_.
|
|
695
|
+
* @param collectionName The base collection name prefix.
|
|
696
|
+
* @internal
|
|
697
|
+
*/
|
|
698
|
+
async deleteAllPartitionCollections(collectionName) {
|
|
699
|
+
const prefix = `${collectionName}_`;
|
|
700
|
+
const collections = await this._firestoreClient.listCollections();
|
|
701
|
+
const chunkSize = 500;
|
|
702
|
+
for (const col of collections) {
|
|
703
|
+
if (col.id.startsWith(prefix)) {
|
|
704
|
+
const snapshot = await col.get();
|
|
705
|
+
for (let i = 0; i < snapshot.docs.length; i += chunkSize) {
|
|
706
|
+
const chunk = snapshot.docs.slice(i, i + chunkSize);
|
|
707
|
+
const batch = this._firestoreClient.batch();
|
|
708
|
+
for (const doc of chunk) {
|
|
709
|
+
batch.delete(doc.ref);
|
|
710
|
+
}
|
|
711
|
+
await batch.commit();
|
|
712
|
+
}
|
|
713
|
+
}
|
|
336
714
|
}
|
|
337
715
|
}
|
|
338
716
|
/**
|
|
339
|
-
*
|
|
717
|
+
* Returns true when the condition tree is guaranteed to match nothing due to empty
|
|
718
|
+
* In lists, respecting AND/OR boolean semantics (#141):
|
|
719
|
+
* - AND group: true if ANY child is always-false (false AND x = false)
|
|
720
|
+
* - OR group: true if ALL children are always-false (false OR false = false)
|
|
721
|
+
* - Leaf: true only for `In []`
|
|
722
|
+
* @param condition The condition tree to inspect.
|
|
723
|
+
* @returns True if a short-circuit to empty results is required.
|
|
724
|
+
* @internal
|
|
725
|
+
*/
|
|
726
|
+
hasEmptyInCondition(condition) {
|
|
727
|
+
if (Is.empty(condition)) {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
if ("conditions" in condition) {
|
|
731
|
+
return condition.logicalOperator === LogicalOperator.Or
|
|
732
|
+
? condition.conditions.every(c => this.hasEmptyInCondition(c))
|
|
733
|
+
: condition.conditions.some(c => this.hasEmptyInCondition(c));
|
|
734
|
+
}
|
|
735
|
+
return (condition.comparison === ComparisonOperator.In &&
|
|
736
|
+
Is.array(condition.value) &&
|
|
737
|
+
condition.value.length === 0);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Returns a copy of the condition tree with all empty-In leaves removed.
|
|
741
|
+
* Used to keep `In []` out of native Firestore Filter calls (the SDK throws on
|
|
742
|
+
* `Filter.where(prop, "in", [])`) while preserving correct OR semantics (#141).
|
|
743
|
+
* Returns null when the entire subtree reduces to nothing (caller should treat
|
|
744
|
+
* as no conditions).
|
|
745
|
+
* @param condition The condition to prune.
|
|
746
|
+
* @returns The pruned condition, or null if the subtree was fully removed.
|
|
747
|
+
* @internal
|
|
748
|
+
*/
|
|
749
|
+
pruneEmptyInConditions(condition) {
|
|
750
|
+
if (!("conditions" in condition)) {
|
|
751
|
+
if (condition.comparison === ComparisonOperator.In &&
|
|
752
|
+
Is.array(condition.value) &&
|
|
753
|
+
condition.value.length === 0) {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
return condition;
|
|
757
|
+
}
|
|
758
|
+
// For AND groups: if any child has an empty In, the whole AND is dead.
|
|
759
|
+
// Do not recurse — promoting the surviving siblings would turn a dead
|
|
760
|
+
// branch into a live one when this AND sits inside an OR (#141).
|
|
761
|
+
if (condition.logicalOperator !== LogicalOperator.Or && this.hasEmptyInCondition(condition)) {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
// For OR groups: prune dead branches individually so the Firestore SDK
|
|
765
|
+
// never receives `In []`, while keeping live siblings.
|
|
766
|
+
const pruned = condition.conditions
|
|
767
|
+
.map(c => this.pruneEmptyInConditions(c))
|
|
768
|
+
.filter((c) => c !== null);
|
|
769
|
+
if (pruned.length === 0) {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
if (pruned.length === 1) {
|
|
773
|
+
return pruned[0];
|
|
774
|
+
}
|
|
775
|
+
return { ...condition, conditions: pruned };
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Returns true when any leaf condition requires client-side filtering
|
|
779
|
+
* (Firestore has no native string-contains / not-contains operator).
|
|
780
|
+
* @param condition The condition tree to inspect.
|
|
781
|
+
* @returns True if post-filtering is required.
|
|
782
|
+
* @internal
|
|
783
|
+
*/
|
|
784
|
+
needsPostFilter(condition) {
|
|
785
|
+
if (Is.empty(condition)) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
if ("conditions" in condition) {
|
|
789
|
+
return condition.conditions.some(c => this.needsPostFilter(c));
|
|
790
|
+
}
|
|
791
|
+
const { comparison, value } = condition;
|
|
792
|
+
if (comparison === ComparisonOperator.NotIncludes) {
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
// Includes on a primitive (string/number) means substring search — not natively supported.
|
|
796
|
+
// Includes on an object means array-contains, which Firestore does support.
|
|
797
|
+
if (comparison === ComparisonOperator.Includes &&
|
|
798
|
+
(value === null || typeof value !== "object")) {
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
return false;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Apply conditions to a Firestore query using composite Filter objects so that
|
|
805
|
+
* OR groups are handled correctly.
|
|
340
806
|
* @param query The initial query.
|
|
341
807
|
* @param condition The condition to apply.
|
|
342
808
|
* @returns The updated query.
|
|
343
809
|
* @internal
|
|
344
810
|
*/
|
|
345
811
|
applyConditions(query, condition) {
|
|
812
|
+
return query.where(this.buildFilter(condition));
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Recursively convert an EntityCondition tree into a Firestore Filter.
|
|
816
|
+
* Only called for native conditions (needsPostFilter must be false).
|
|
817
|
+
* @param condition The condition to convert.
|
|
818
|
+
* @returns A Firestore Filter.
|
|
819
|
+
* @throws GeneralError if the comparison operator is not supported.
|
|
820
|
+
* @internal
|
|
821
|
+
*/
|
|
822
|
+
buildFilter(condition) {
|
|
346
823
|
if ("conditions" in condition) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return query;
|
|
824
|
+
const filters = condition.conditions.map(c => this.buildFilter(c));
|
|
825
|
+
return condition.logicalOperator === LogicalOperator.Or
|
|
826
|
+
? Filter.or(...filters)
|
|
827
|
+
: Filter.and(...filters);
|
|
352
828
|
}
|
|
353
|
-
// It's a single condition
|
|
354
829
|
const { property, comparison } = condition;
|
|
355
|
-
// Firestore has no undefined type —
|
|
356
|
-
// For Equals/NotEquals, null already has the correct semantics:
|
|
830
|
+
// Firestore has no undefined type — null has the correct semantics:
|
|
357
831
|
// == null matches documents where the field is null OR missing
|
|
358
832
|
// != null matches documents where the field exists and is not null
|
|
359
833
|
const value = condition.value === undefined ? null : condition.value;
|
|
360
834
|
switch (comparison) {
|
|
361
835
|
case ComparisonOperator.Equals:
|
|
362
|
-
return
|
|
836
|
+
return Filter.where(property, "==", value);
|
|
363
837
|
case ComparisonOperator.NotEquals:
|
|
364
|
-
return
|
|
838
|
+
return Filter.where(property, "!=", value);
|
|
365
839
|
case ComparisonOperator.GreaterThan:
|
|
366
|
-
return
|
|
840
|
+
return Filter.where(property, ">", value);
|
|
367
841
|
case ComparisonOperator.LessThan:
|
|
368
|
-
return
|
|
842
|
+
return Filter.where(property, "<", value);
|
|
369
843
|
case ComparisonOperator.GreaterThanOrEqual:
|
|
370
|
-
return
|
|
844
|
+
return Filter.where(property, ">=", value);
|
|
371
845
|
case ComparisonOperator.LessThanOrEqual:
|
|
372
|
-
return
|
|
846
|
+
return Filter.where(property, "<=", value);
|
|
373
847
|
case ComparisonOperator.In:
|
|
374
|
-
return
|
|
848
|
+
return Filter.where(property, "in", value);
|
|
375
849
|
case ComparisonOperator.Includes:
|
|
376
|
-
|
|
850
|
+
// Object value → array-contains (caller ensured needsPostFilter is false here)
|
|
851
|
+
return Filter.where(property, "array-contains", value);
|
|
377
852
|
case ComparisonOperator.NotIncludes:
|
|
378
853
|
default:
|
|
379
854
|
throw new GeneralError(FirestoreEntityStorageConnector.CLASS_NAME, "unsupportedComparisonOperator", { comparison });
|
|
380
855
|
}
|
|
381
856
|
}
|
|
382
|
-
/**
|
|
383
|
-
* Delete all entities in the collection.
|
|
384
|
-
* @returns Nothing.
|
|
385
|
-
* @internal
|
|
386
|
-
*/
|
|
387
|
-
async deleteQueryBatch(query, batchSize) {
|
|
388
|
-
const snapshot = await query.get();
|
|
389
|
-
if (snapshot.size === 0) {
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
const batch = this._firestoreClient.batch();
|
|
393
|
-
for (const doc of snapshot.docs) {
|
|
394
|
-
batch.delete(doc.ref);
|
|
395
|
-
}
|
|
396
|
-
await batch.commit();
|
|
397
|
-
if (snapshot.size === batchSize) {
|
|
398
|
-
await this.deleteQueryBatch(query, batchSize);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
857
|
/**
|
|
402
858
|
* Get the collection name based on partition key.
|
|
859
|
+
* @param partitionKey The optional partition key to include in the collection name.
|
|
403
860
|
* @returns The collection name.
|
|
404
861
|
* @internal
|
|
405
862
|
*/
|