@twin.org/entity-storage-connector-mongodb 0.0.3-next.3 → 0.0.3-next.30

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 CHANGED
@@ -1,6 +1,6 @@
1
- # TWIN Entity Storage Connector MongoDb
1
+ # Entity Storage Connector MongoDB
2
2
 
3
- Entity Storage connector implementation using MongoDb storage.
3
+ This package provides a MongoDB backend for flexible document persistence and evolving schemas. 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,18 +8,13 @@ Entity Storage connector implementation using MongoDb storage.
8
8
  npm install @twin.org/entity-storage-connector-mongodb
9
9
  ```
10
10
 
11
- ## Testing
11
+ ## Docker
12
12
 
13
- The tests developed are functional tests and need an instance of MongoDb up and running. To run MongoDb locally:
13
+ To perform testing of this component it may be necessary to launch a local instance to communicate with.
14
14
 
15
- ```sh
16
- docker run -p 27500:27017 --name twin-entity-storage-mongodb --hostname mongo -d mongo
17
- ```
18
-
19
- Afterwards you can run the tests as follows:
20
-
21
- ```sh
22
- npm run test
15
+ ```shell
16
+ docker pull mongo:latest
17
+ docker run -d --name twin-entity-storage-mongodb -p 27500:27017 mongo:latest
23
18
  ```
24
19
 
25
20
  ## Examples
@@ -1 +1 @@
1
- {"version":3,"file":"IMongoDbEntityStorageConnectorConstructorOptions.js","sourceRoot":"","sources":["../../../src/models/IMongoDbEntityStorageConnectorConstructorOptions.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IMongoDbEntityStorageConnectorConfig } from \"./IMongoDbEntityStorageConnectorConfig.js\";\n\n/**\n * The options for the MongoDb entity storage connector constructor.\n */\nexport interface IMongoDbEntityStorageConnectorConstructorOptions {\n\t/**\n\t * The schema for the entity.\n\t */\n\tentitySchema: string;\n\n\t/**\n\t * The keys to use from the context ids to create partitions.\n\t */\n\tpartitionContextIds?: string[];\n\n\t/**\n\t * The type of logging component to use.\n\t * @default logging\n\t */\n\tloggingComponentType?: string;\n\n\t/**\n\t * The configuration for the connector.\n\t */\n\tconfig: IMongoDbEntityStorageConnectorConfig;\n}\n"]}
1
+ {"version":3,"file":"IMongoDbEntityStorageConnectorConstructorOptions.js","sourceRoot":"","sources":["../../../src/models/IMongoDbEntityStorageConnectorConstructorOptions.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IMongoDbEntityStorageConnectorConfig } from \"./IMongoDbEntityStorageConnectorConfig.js\";\n\n/**\n * The options for the MongoDb entity storage connector constructor.\n */\nexport interface IMongoDbEntityStorageConnectorConstructorOptions {\n\t/**\n\t * The schema for the entity.\n\t */\n\tentitySchema: string;\n\n\t/**\n\t * The keys to use from the context ids to create partitions.\n\t */\n\tpartitionContextIds?: string[];\n\n\t/**\n\t * The type of logging component to use.\n\t */\n\tloggingComponentType?: string;\n\n\t/**\n\t * The configuration for the connector.\n\t */\n\tconfig: IMongoDbEntityStorageConnectorConfig;\n}\n"]}
@@ -1,8 +1,9 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
3
  import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
4
- import { BaseError, ComponentFactory, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
5
- import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, LogicalOperator } from "@twin.org/entity";
4
+ import { BaseError, ComponentFactory, GeneralError, Guards, HealthStatus, Is, ObjectHelper, Validation } from "@twin.org/core";
5
+ import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, EntitySchemaPropertyType, LogicalOperator } from "@twin.org/entity";
6
+ import { EntityStorageHelper } from "@twin.org/entity-storage-models";
6
7
  import { MongoClient } from "mongodb";
7
8
  /**
8
9
  * Class for performing entity storage operations using MongoDb.
@@ -18,10 +19,10 @@ export class MongoDbEntityStorageConnector {
18
19
  */
19
20
  static _DEFAULT_LIMIT = 40;
20
21
  /**
21
- * Partition id field name.
22
+ * The name for the schema.
22
23
  * @internal
23
24
  */
24
- static _PARTITION_KEY = "partitionId";
25
+ _entitySchemaName;
25
26
  /**
26
27
  * The schema for the entity.
27
28
  * @internal
@@ -53,6 +54,7 @@ export class MongoDbEntityStorageConnector {
53
54
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "options.config.host", options.config.host);
54
55
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "options.config.database", options.config.database);
55
56
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "options.config.collection", options.config.collection);
57
+ this._entitySchemaName = options.entitySchema;
56
58
  this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
57
59
  this._partitionContextIds = options.partitionContextIds;
58
60
  this._config = options.config;
@@ -113,6 +115,14 @@ export class MongoDbEntityStorageConnector {
113
115
  }
114
116
  return true;
115
117
  }
118
+ /**
119
+ * The component needs to be stopped when the node is closed.
120
+ * @param nodeLoggingComponentType The node logging component type.
121
+ * @returns Nothing.
122
+ */
123
+ async stop(nodeLoggingComponentType) {
124
+ await this._client.close();
125
+ }
116
126
  /**
117
127
  * Returns the class name of the component.
118
128
  * @returns The class name of the component.
@@ -120,6 +130,37 @@ export class MongoDbEntityStorageConnector {
120
130
  className() {
121
131
  return MongoDbEntityStorageConnector.CLASS_NAME;
122
132
  }
133
+ /**
134
+ * Returns the health status of the component.
135
+ * @returns The health status of the component.
136
+ */
137
+ async health() {
138
+ try {
139
+ await this._client
140
+ .db(this._config.database)
141
+ .collection(this._config.collection)
142
+ .estimatedDocumentCount();
143
+ return [
144
+ {
145
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
146
+ status: HealthStatus.Ok,
147
+ description: "healthDescription",
148
+ data: { database: this._config.database, collection: this._config.collection }
149
+ }
150
+ ];
151
+ }
152
+ catch {
153
+ return [
154
+ {
155
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
156
+ status: HealthStatus.Error,
157
+ description: "healthDescription",
158
+ message: "connectionFailed",
159
+ data: { database: this._config.database, collection: this._config.collection }
160
+ }
161
+ ];
162
+ }
163
+ }
123
164
  /**
124
165
  * Get the schema for the entities.
125
166
  * @returns The schema for the entities.
@@ -136,16 +177,11 @@ export class MongoDbEntityStorageConnector {
136
177
  */
137
178
  async get(id, secondaryIndex, conditions) {
138
179
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "id", id);
139
- const contextIds = await ContextIdStore.getContextIds();
140
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
141
180
  try {
142
181
  const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
143
182
  const query = Is.empty(secondaryIndex)
144
183
  ? { [primaryKey.property]: id }
145
184
  : { [secondaryIndex]: id };
146
- if (Is.stringValue(partitionKey)) {
147
- query[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
148
- }
149
185
  if (conditions) {
150
186
  for (const condition of conditions) {
151
187
  query[condition.property] = condition.value;
@@ -154,8 +190,9 @@ export class MongoDbEntityStorageConnector {
154
190
  const collection = await this.getCollection();
155
191
  const result = await collection.findOne(query);
156
192
  ObjectHelper.propertyDelete(result, "_id");
157
- ObjectHelper.propertyDelete(result, MongoDbEntityStorageConnector._PARTITION_KEY);
158
- return result;
193
+ return Is.objectValue(result)
194
+ ? EntityStorageHelper.unPrepareEntity(result, [])
195
+ : undefined;
159
196
  }
160
197
  catch (err) {
161
198
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "getFailed", {
@@ -171,25 +208,20 @@ export class MongoDbEntityStorageConnector {
171
208
  */
172
209
  async set(entity, conditions) {
173
210
  Guards.object(MongoDbEntityStorageConnector.CLASS_NAME, "entity", entity);
174
- const contextIds = await ContextIdStore.getContextIds();
175
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
176
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
211
+ const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
212
+ nullBehavior: "omit"
213
+ });
177
214
  const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
178
- const id = entity[primaryKey.property];
215
+ const id = prepared[primaryKey.property];
179
216
  try {
180
217
  const filter = { [primaryKey.property]: id };
181
- const finalEntity = ObjectHelper.clone(entity);
182
- if (Is.stringValue(partitionKey)) {
183
- filter[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
184
- ObjectHelper.propertySet(finalEntity, MongoDbEntityStorageConnector._PARTITION_KEY, partitionKey);
185
- }
186
218
  if (Is.arrayValue(conditions)) {
187
219
  for (const condition of conditions) {
188
220
  filter[condition.property] = condition.value;
189
221
  }
190
222
  }
191
223
  const collection = await this.getCollection();
192
- await collection.findOneAndUpdate(filter, { $set: ObjectHelper.removeEmptyProperties(finalEntity) }, { upsert: true });
224
+ await collection.findOneAndUpdate(filter, { $set: prepared }, { upsert: true });
193
225
  }
194
226
  catch (err) {
195
227
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "setFailed", {
@@ -197,6 +229,51 @@ export class MongoDbEntityStorageConnector {
197
229
  }, err);
198
230
  }
199
231
  }
232
+ /**
233
+ * Set multiple entities in a batch.
234
+ * @param entities The entities to set.
235
+ * @returns Nothing.
236
+ */
237
+ async setBatch(entities) {
238
+ Guards.arrayValue(MongoDbEntityStorageConnector.CLASS_NAME, "entities", entities);
239
+ const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
240
+ const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, undefined, {
241
+ nullBehavior: "omit"
242
+ }));
243
+ try {
244
+ const collection = await this.getCollection();
245
+ await collection.bulkWrite(preparedEntities.map(prepared => {
246
+ const filter = {
247
+ [primaryKey.property]: prepared[primaryKey.property]
248
+ };
249
+ return {
250
+ updateOne: {
251
+ filter,
252
+ update: {
253
+ $set: prepared
254
+ },
255
+ upsert: true
256
+ }
257
+ };
258
+ }));
259
+ }
260
+ catch (err) {
261
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
262
+ }
263
+ }
264
+ /**
265
+ * Empty the entity storage.
266
+ * @returns Nothing.
267
+ */
268
+ async empty() {
269
+ try {
270
+ const collection = await this.getCollection();
271
+ await collection.deleteMany({});
272
+ }
273
+ catch (err) {
274
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
275
+ }
276
+ }
200
277
  /**
201
278
  * Remove the entity.
202
279
  * @param id The id of the entity to remove.
@@ -205,14 +282,9 @@ export class MongoDbEntityStorageConnector {
205
282
  */
206
283
  async remove(id, conditions) {
207
284
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "id", id);
208
- const contextIds = await ContextIdStore.getContextIds();
209
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
210
285
  try {
211
286
  const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
212
287
  const query = { [primaryKey.property]: id };
213
- if (Is.stringValue(partitionKey)) {
214
- query[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
215
- }
216
288
  if (conditions) {
217
289
  for (const condition of conditions) {
218
290
  query[condition.property] = condition.value;
@@ -225,6 +297,62 @@ export class MongoDbEntityStorageConnector {
225
297
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "removeFailed", { id }, err);
226
298
  }
227
299
  }
300
+ /**
301
+ * Remove multiple entities by id.
302
+ * @param ids The ids of the entities to remove.
303
+ * @returns Nothing.
304
+ */
305
+ async removeBatch(ids) {
306
+ Guards.arrayValue(MongoDbEntityStorageConnector.CLASS_NAME, "ids", ids);
307
+ try {
308
+ const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
309
+ const filter = {
310
+ [primaryKey.property]: { $in: ids }
311
+ };
312
+ const collection = await this.getCollection();
313
+ await collection.deleteMany(filter);
314
+ }
315
+ catch (err) {
316
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
317
+ }
318
+ }
319
+ /**
320
+ * Teardown the entity storage by dropping the collection.
321
+ * @param nodeLoggingComponentType The node logging component type.
322
+ * @returns True if the teardown process was successful.
323
+ */
324
+ async teardown(nodeLoggingComponentType) {
325
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
326
+ await nodeLogging?.log({
327
+ level: "info",
328
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
329
+ ts: Date.now(),
330
+ message: "collectionDropping",
331
+ data: { collection: this._config.collection }
332
+ });
333
+ try {
334
+ const collection = await this.getCollection();
335
+ await collection.drop();
336
+ await nodeLogging?.log({
337
+ level: "info",
338
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
339
+ ts: Date.now(),
340
+ message: "collectionDropped",
341
+ data: { collection: this._config.collection }
342
+ });
343
+ return true;
344
+ }
345
+ catch (err) {
346
+ await nodeLogging?.log({
347
+ level: "error",
348
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
349
+ ts: Date.now(),
350
+ message: "teardownFailed",
351
+ error: BaseError.fromError(err)
352
+ });
353
+ return false;
354
+ }
355
+ }
228
356
  /**
229
357
  * Find all the entities which match the conditions.
230
358
  * @param conditions The conditions to match for the entities.
@@ -236,27 +364,15 @@ export class MongoDbEntityStorageConnector {
236
364
  * and a cursor which can be used to request more entities.
237
365
  */
238
366
  async query(conditions, sortProperties, properties, cursor, limit) {
239
- const contextIds = await ContextIdStore.getContextIds();
240
- const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
241
- const returnSize = limit ?? MongoDbEntityStorageConnector._DEFAULT_LIMIT;
242
- const finalConditions = {
243
- conditions: [],
244
- logicalOperator: LogicalOperator.And
245
- };
246
- if (Is.stringValue(partitionKey)) {
247
- finalConditions.conditions.push({
248
- property: MongoDbEntityStorageConnector._PARTITION_KEY,
249
- comparison: ComparisonOperator.Equals,
250
- value: partitionKey
251
- });
252
- }
253
- if (!Is.empty(conditions)) {
254
- finalConditions.conditions.push(conditions);
255
- }
256
- const filter = {};
257
- if (finalConditions.conditions.length > 0) {
258
- this.buildQueryParameters("", finalConditions, filter);
367
+ EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
368
+ EntityStorageHelper.validateProperties(this._entitySchema, properties);
369
+ if (!Is.empty(limit)) {
370
+ const validationFailures = [];
371
+ Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
372
+ Validation.asValidationError(MongoDbEntityStorageConnector.CLASS_NAME, "query", validationFailures);
259
373
  }
374
+ const returnSize = limit ?? MongoDbEntityStorageConnector._DEFAULT_LIMIT;
375
+ const filter = this.buildFilter(conditions);
260
376
  const sort = new Map();
261
377
  if (Array.isArray(sortProperties)) {
262
378
  for (const sortProperty of sortProperties) {
@@ -277,31 +393,123 @@ export class MongoDbEntityStorageConnector {
277
393
  ?.find(filter, { projection })
278
394
  .sort(sort)
279
395
  .skip(cursorValue)
280
- .limit(returnSize)
396
+ .limit(returnSize + 1)
281
397
  .toArray();
282
- const entities = entitiesResult ?? [];
283
- for (const entity of entities) {
398
+ const rawResults = entitiesResult ?? [];
399
+ const hasMore = rawResults.length > returnSize;
400
+ const entities = hasMore ? rawResults.slice(0, returnSize) : rawResults;
401
+ for (let i = 0; i < entities.length; i++) {
402
+ const entity = entities[i];
284
403
  ObjectHelper.propertyDelete(entity, "_id");
285
- ObjectHelper.propertyDelete(entity, MongoDbEntityStorageConnector._PARTITION_KEY);
404
+ entities[i] = EntityStorageHelper.unPrepareEntity(entity, []);
286
405
  }
287
406
  return {
288
407
  entities,
289
- cursor: entities?.length === returnSize ? String(cursorValue + returnSize) : undefined
408
+ cursor: hasMore ? String(cursorValue + returnSize) : undefined
290
409
  };
291
410
  }
292
411
  /**
293
- * Drop the collection.
294
- * @returns Nothing.
412
+ * Count all the entities which match the conditions.
413
+ * @param conditions The optional conditions to match for the entities.
414
+ * @returns The total count of entities in the storage.
295
415
  */
296
- async collectionDrop() {
416
+ async count(conditions) {
297
417
  try {
418
+ const filter = this.buildFilter(conditions);
298
419
  const collection = await this.getCollection();
299
- await collection.drop();
420
+ return await collection.countDocuments(filter);
300
421
  }
301
- catch {
302
- // Ignore errors
422
+ catch (err) {
423
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
424
+ }
425
+ }
426
+ /**
427
+ * Get all unique partition context ids present in the collection.
428
+ * @returns An array of context id objects, one per unique partition.
429
+ */
430
+ async getPartitionContextIds() {
431
+ if (!Is.arrayValue(this._partitionContextIds)) {
432
+ return [];
433
+ }
434
+ try {
435
+ const prefix = `${this._config.collection}_`;
436
+ const escapedPrefix = prefix.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
437
+ const db = this._client.db(this._config.database);
438
+ const collections = await db
439
+ .listCollections({ name: { $regex: `^${escapedPrefix}` } })
440
+ .toArray();
441
+ return collections.map(col => ContextIdHelper.shortSplit(this._partitionContextIds ?? [], col.name.slice(prefix.length)));
442
+ }
443
+ catch (err) {
444
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
303
445
  }
304
446
  }
447
+ /**
448
+ * Create the target connector for performing the migration using a temporary collection.
449
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
450
+ * @returns Connector for performing the migration.
451
+ */
452
+ async createTargetConnector(newEntitySchema) {
453
+ const migrationCollectionName = `${this._config.collection}Migration${Date.now()}`;
454
+ return new MongoDbEntityStorageConnector({
455
+ entitySchema: newEntitySchema,
456
+ config: {
457
+ ...this._config,
458
+ collection: migrationCollectionName
459
+ },
460
+ partitionContextIds: this._partitionContextIds
461
+ });
462
+ }
463
+ /**
464
+ * Finalize the migration by dropping the source collection and renaming the migration collection to the original name.
465
+ * @param targetConnector The connector holding the migrated data in a temporary collection.
466
+ * @param options The options to control how the migration is finalized.
467
+ * @param loggingComponentType The logging component type to use during finalization.
468
+ * @returns The final connector using the original collection name with the new schema.
469
+ */
470
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
471
+ // With collection-per-partition each partition is a separate collection, so we must
472
+ // rename every target partition collection to the corresponding source name. We do this
473
+ // without relying on context so that all partitions are handled in a single call.
474
+ const targetBase = targetConnector._config.collection;
475
+ const sourceBase = this._config.collection;
476
+ const targetDb = targetConnector._client.db(targetConnector._config.database);
477
+ const sourceDb = this._client.db(this._config.database);
478
+ // Find all collections the target connector wrote to (exact base name or with a _suffix).
479
+ const allCollections = await targetDb.listCollections().toArray();
480
+ const migrationCollections = allCollections.filter(c => c.name === targetBase || c.name.startsWith(`${targetBase}_`));
481
+ for (const col of migrationCollections) {
482
+ // Preserve whatever suffix (empty, or "_partitionKey") was appended to the base name.
483
+ const suffix = col.name.slice(targetBase.length);
484
+ const finalName = `${sourceBase}${suffix}`;
485
+ // Drop the existing source collection to free up the name.
486
+ try {
487
+ await sourceDb.collection(finalName).drop();
488
+ }
489
+ catch { } // collection may not exist yet
490
+ await targetDb.collection(col.name).rename(finalName);
491
+ }
492
+ const finalConnector = new MongoDbEntityStorageConnector({
493
+ entitySchema: targetConnector._entitySchemaName,
494
+ config: this._config,
495
+ partitionContextIds: this._partitionContextIds
496
+ });
497
+ if (await finalConnector.bootstrap(loggingComponentType)) {
498
+ await targetConnector.stop?.();
499
+ return finalConnector;
500
+ }
501
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
502
+ }
503
+ /**
504
+ * Cleanup a failed or aborted migration by dropping the temporary migration collection.
505
+ * @param targetConnector The target connector to cleanup.
506
+ * @param options The options to control how the migration is cleaned up.
507
+ * @param loggingComponentType The optional component type to use for logging.
508
+ * @returns A promise that resolves when the cleanup is complete.
509
+ */
510
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
511
+ await targetConnector?.teardown?.(loggingComponentType);
512
+ }
305
513
  /**
306
514
  * Create a new DB connection configuration.
307
515
  * @returns The MongoDb connection configuration.
@@ -316,13 +524,44 @@ export class MongoDbEntityStorageConnector {
316
524
  return `mongodb://${host}${portPart}/${database}`;
317
525
  }
318
526
  /**
319
- * Return a Mongo DB collection.
527
+ * Return a Mongo DB collection for the current partition context.
320
528
  * @returns The MongoDb collection.
321
529
  * @internal
322
530
  */
323
531
  async getCollection() {
324
- const { database, collection } = this._config;
325
- return this._client.db(database).collection(collection);
532
+ const collectionName = await this.resolveCollectionName(this._config.collection);
533
+ return this._client.db(this._config.database).collection(collectionName);
534
+ }
535
+ /**
536
+ * Resolve the collection name for a base name, appending the partition key when applicable.
537
+ * @param base The base collection name.
538
+ * @returns The resolved collection name.
539
+ * @internal
540
+ */
541
+ async resolveCollectionName(base) {
542
+ if (!Is.arrayValue(this._partitionContextIds)) {
543
+ return base;
544
+ }
545
+ const contextIds = await ContextIdStore.getContextIds();
546
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
547
+ return Is.stringValue(partitionKey) ? `${base}_${partitionKey.replace(/[\0$]/g, "_")}` : base;
548
+ }
549
+ /**
550
+ * Build a MongoDB filter from optional conditions.
551
+ * @param conditions The optional entity conditions to include.
552
+ * @returns The MongoDB filter object.
553
+ * @internal
554
+ */
555
+ buildFilter(conditions) {
556
+ const filter = {};
557
+ if (!Is.empty(conditions)) {
558
+ const finalConditions = {
559
+ conditions: [conditions],
560
+ logicalOperator: LogicalOperator.And
561
+ };
562
+ this.buildQueryParameters("", finalConditions, filter);
563
+ }
564
+ return filter;
326
565
  }
327
566
  /**
328
567
  * Create an MongoDB filter query.
@@ -352,8 +591,17 @@ export class MongoDbEntityStorageConnector {
352
591
  }
353
592
  }
354
593
  else {
355
- const prop = objectPath ? `${objectPath}.${condition.property}` : String(condition.property);
356
- const comparison = this.mapComparisonOperator(condition.comparison, condition.value);
594
+ const propertyPath = String(condition.property);
595
+ const prop = objectPath ? `${objectPath}.${propertyPath}` : propertyPath;
596
+ const propertyParts = propertyPath.split(".");
597
+ const schemaLookupName = propertyParts.length > 1 ? propertyParts[0] : propertyPath;
598
+ const propertySchema = this._entitySchema.properties?.find(p => p.property === schemaLookupName);
599
+ // For dot-notation paths the leaf field is always a string value; using the root
600
+ // type directly would send Includes into $elemMatch which does not work for nested
601
+ // string fields. Keeping String here causes mapComparisonOperator to emit $regex,
602
+ // which MongoDB handles correctly for both nested object and array traversal.
603
+ const propertyType = propertyParts.length > 1 ? EntitySchemaPropertyType.String : propertySchema?.type;
604
+ const comparison = this.mapComparisonOperator(condition.comparison, condition.value, propertyType);
357
605
  filter[prop] = comparison;
358
606
  }
359
607
  }
@@ -361,10 +609,12 @@ export class MongoDbEntityStorageConnector {
361
609
  * Map the framework comparison operators to those in MongoDB.
362
610
  * @param comparison The comparison operator.
363
611
  * @param value The value to compare.
612
+ * @param type The type of the property from the schema.
364
613
  * @returns The MongoDB comparison expression.
614
+ * @throws GeneralError if the comparison operator is not supported.
365
615
  * @internal
366
616
  */
367
- mapComparisonOperator(comparison, value) {
617
+ mapComparisonOperator(comparison, value, type) {
368
618
  switch (comparison) {
369
619
  case ComparisonOperator.Equals:
370
620
  return value;
@@ -381,9 +631,28 @@ export class MongoDbEntityStorageConnector {
381
631
  case ComparisonOperator.In:
382
632
  return { $in: Array.isArray(value) ? value : [value] };
383
633
  case ComparisonOperator.Includes:
634
+ // For string fields, use regex for substring matching
635
+ if (type === EntitySchemaPropertyType.String) {
636
+ // Escape special regex characters in the value
637
+ const escapedValue = String(value).replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
638
+ return { $regex: escapedValue };
639
+ }
640
+ // For array and object fields, use $elemMatch
641
+ if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
642
+ return { $elemMatch: { $eq: value } };
643
+ }
644
+ // Fallback to $elemMatch for backwards compatibility
384
645
  return { $elemMatch: { $eq: value } };
385
646
  case ComparisonOperator.NotIncludes:
386
- return { $elemMatch: { $ne: value } };
647
+ // For string fields, use negated regex
648
+ if (type === EntitySchemaPropertyType.String) {
649
+ const escapedValue = String(value).replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
650
+ return { $not: { $regex: escapedValue } };
651
+ }
652
+ // For array/object fields: $ne on an array field matches documents where
653
+ // none of the array elements equal the value (MongoDB element-wise semantics).
654
+ // $elemMatch: { $ne: value } is wrong — it matches if *any* element ≠ value.
655
+ return { $ne: value };
387
656
  default:
388
657
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "unsupportedComparisonOperator", { comparison });
389
658
  }