@twin.org/entity-storage-connector-mongodb 0.0.3-next.2 → 0.0.3-next.21

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,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 } 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.
@@ -22,6 +23,11 @@ export class MongoDbEntityStorageConnector {
22
23
  * @internal
23
24
  */
24
25
  static _PARTITION_KEY = "partitionId";
26
+ /**
27
+ * The name for the schema.
28
+ * @internal
29
+ */
30
+ _entitySchemaName;
25
31
  /**
26
32
  * The schema for the entity.
27
33
  * @internal
@@ -53,6 +59,7 @@ export class MongoDbEntityStorageConnector {
53
59
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "options.config.host", options.config.host);
54
60
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "options.config.database", options.config.database);
55
61
  Guards.stringValue(MongoDbEntityStorageConnector.CLASS_NAME, "options.config.collection", options.config.collection);
62
+ this._entitySchemaName = options.entitySchema;
56
63
  this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
57
64
  this._partitionContextIds = options.partitionContextIds;
58
65
  this._config = options.config;
@@ -113,6 +120,14 @@ export class MongoDbEntityStorageConnector {
113
120
  }
114
121
  return true;
115
122
  }
123
+ /**
124
+ * The component needs to be stopped when the node is closed.
125
+ * @param nodeLoggingComponentType The node logging component type.
126
+ * @returns Nothing.
127
+ */
128
+ async stop(nodeLoggingComponentType) {
129
+ await this._client.close();
130
+ }
116
131
  /**
117
132
  * Returns the class name of the component.
118
133
  * @returns The class name of the component.
@@ -120,6 +135,37 @@ export class MongoDbEntityStorageConnector {
120
135
  className() {
121
136
  return MongoDbEntityStorageConnector.CLASS_NAME;
122
137
  }
138
+ /**
139
+ * Returns the health status of the component.
140
+ * @returns The health status of the component.
141
+ */
142
+ async health() {
143
+ try {
144
+ await this._client
145
+ .db(this._config.database)
146
+ .collection(this._config.collection)
147
+ .estimatedDocumentCount();
148
+ return [
149
+ {
150
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
151
+ status: HealthStatus.Ok,
152
+ description: "healthDescription",
153
+ data: { database: this._config.database, collection: this._config.collection }
154
+ }
155
+ ];
156
+ }
157
+ catch {
158
+ return [
159
+ {
160
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
161
+ status: HealthStatus.Error,
162
+ description: "healthDescription",
163
+ message: "connectionFailed",
164
+ data: { database: this._config.database, collection: this._config.collection }
165
+ }
166
+ ];
167
+ }
168
+ }
123
169
  /**
124
170
  * Get the schema for the entities.
125
171
  * @returns The schema for the entities.
@@ -154,8 +200,11 @@ export class MongoDbEntityStorageConnector {
154
200
  const collection = await this.getCollection();
155
201
  const result = await collection.findOne(query);
156
202
  ObjectHelper.propertyDelete(result, "_id");
157
- ObjectHelper.propertyDelete(result, MongoDbEntityStorageConnector._PARTITION_KEY);
158
- return result;
203
+ return Is.objectValue(result)
204
+ ? EntityStorageHelper.unPrepareEntity(result, [
205
+ MongoDbEntityStorageConnector._PARTITION_KEY
206
+ ])
207
+ : undefined;
159
208
  }
160
209
  catch (err) {
161
210
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "getFailed", {
@@ -173,15 +222,15 @@ export class MongoDbEntityStorageConnector {
173
222
  Guards.object(MongoDbEntityStorageConnector.CLASS_NAME, "entity", entity);
174
223
  const contextIds = await ContextIdStore.getContextIds();
175
224
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
176
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
225
+ const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, Is.stringValue(partitionKey)
226
+ ? [{ property: MongoDbEntityStorageConnector._PARTITION_KEY, value: partitionKey }]
227
+ : undefined, { nullBehavior: "omit" });
177
228
  const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
178
- const id = entity[primaryKey.property];
229
+ const id = prepared[primaryKey.property];
179
230
  try {
180
231
  const filter = { [primaryKey.property]: id };
181
- const finalEntity = ObjectHelper.clone(entity);
182
232
  if (Is.stringValue(partitionKey)) {
183
233
  filter[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
184
- ObjectHelper.propertySet(finalEntity, MongoDbEntityStorageConnector._PARTITION_KEY, partitionKey);
185
234
  }
186
235
  if (Is.arrayValue(conditions)) {
187
236
  for (const condition of conditions) {
@@ -189,7 +238,7 @@ export class MongoDbEntityStorageConnector {
189
238
  }
190
239
  }
191
240
  const collection = await this.getCollection();
192
- await collection.findOneAndUpdate(filter, { $set: ObjectHelper.removeEmptyProperties(finalEntity) }, { upsert: true });
241
+ await collection.findOneAndUpdate(filter, { $set: prepared }, { upsert: true });
193
242
  }
194
243
  catch (err) {
195
244
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "setFailed", {
@@ -197,6 +246,62 @@ export class MongoDbEntityStorageConnector {
197
246
  }, err);
198
247
  }
199
248
  }
249
+ /**
250
+ * Set multiple entities in a batch.
251
+ * @param entities The entities to set.
252
+ * @returns Nothing.
253
+ */
254
+ async setBatch(entities) {
255
+ Guards.arrayValue(MongoDbEntityStorageConnector.CLASS_NAME, "entities", entities);
256
+ const contextIds = await ContextIdStore.getContextIds();
257
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
258
+ const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
259
+ const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, Is.stringValue(partitionKey)
260
+ ? [{ property: MongoDbEntityStorageConnector._PARTITION_KEY, value: partitionKey }]
261
+ : undefined, { nullBehavior: "omit" }));
262
+ try {
263
+ const collection = await this.getCollection();
264
+ await collection.bulkWrite(preparedEntities.map(prepared => {
265
+ const filter = {
266
+ [primaryKey.property]: prepared[primaryKey.property]
267
+ };
268
+ if (Is.stringValue(partitionKey)) {
269
+ filter[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
270
+ }
271
+ return {
272
+ updateOne: {
273
+ filter,
274
+ update: {
275
+ $set: prepared
276
+ },
277
+ upsert: true
278
+ }
279
+ };
280
+ }));
281
+ }
282
+ catch (err) {
283
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
284
+ }
285
+ }
286
+ /**
287
+ * Empty the entity storage.
288
+ * @returns Nothing.
289
+ */
290
+ async empty() {
291
+ try {
292
+ const contextIds = await ContextIdStore.getContextIds();
293
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
294
+ const filter = {};
295
+ if (Is.stringValue(partitionKey)) {
296
+ filter[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
297
+ }
298
+ const collection = await this.getCollection();
299
+ await collection.deleteMany(filter);
300
+ }
301
+ catch (err) {
302
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
303
+ }
304
+ }
200
305
  /**
201
306
  * Remove the entity.
202
307
  * @param id The id of the entity to remove.
@@ -225,6 +330,67 @@ export class MongoDbEntityStorageConnector {
225
330
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "removeFailed", { id }, err);
226
331
  }
227
332
  }
333
+ /**
334
+ * Remove multiple entities by id.
335
+ * @param ids The ids of the entities to remove.
336
+ * @returns Nothing.
337
+ */
338
+ async removeBatch(ids) {
339
+ Guards.arrayValue(MongoDbEntityStorageConnector.CLASS_NAME, "ids", ids);
340
+ const contextIds = await ContextIdStore.getContextIds();
341
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
342
+ try {
343
+ const primaryKey = EntitySchemaHelper.getPrimaryKey(this.getSchema());
344
+ const filter = {
345
+ [primaryKey.property]: { $in: ids }
346
+ };
347
+ if (Is.stringValue(partitionKey)) {
348
+ filter[MongoDbEntityStorageConnector._PARTITION_KEY] = partitionKey;
349
+ }
350
+ const collection = await this.getCollection();
351
+ await collection.deleteMany(filter);
352
+ }
353
+ catch (err) {
354
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
355
+ }
356
+ }
357
+ /**
358
+ * Teardown the entity storage by dropping the collection.
359
+ * @param nodeLoggingComponentType The node logging component type.
360
+ * @returns True if the teardown process was successful.
361
+ */
362
+ async teardown(nodeLoggingComponentType) {
363
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
364
+ await nodeLogging?.log({
365
+ level: "info",
366
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
367
+ ts: Date.now(),
368
+ message: "collectionDropping",
369
+ data: { collection: this._config.collection }
370
+ });
371
+ try {
372
+ const collection = await this.getCollection();
373
+ await collection.drop();
374
+ await nodeLogging?.log({
375
+ level: "info",
376
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
377
+ ts: Date.now(),
378
+ message: "collectionDropped",
379
+ data: { collection: this._config.collection }
380
+ });
381
+ return true;
382
+ }
383
+ catch (err) {
384
+ await nodeLogging?.log({
385
+ level: "error",
386
+ source: MongoDbEntityStorageConnector.CLASS_NAME,
387
+ ts: Date.now(),
388
+ message: "teardownFailed",
389
+ error: BaseError.fromError(err)
390
+ });
391
+ return false;
392
+ }
393
+ }
228
394
  /**
229
395
  * Find all the entities which match the conditions.
230
396
  * @param conditions The conditions to match for the entities.
@@ -239,24 +405,7 @@ export class MongoDbEntityStorageConnector {
239
405
  const contextIds = await ContextIdStore.getContextIds();
240
406
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
241
407
  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);
259
- }
408
+ const filter = this.buildFilter(conditions, partitionKey);
260
409
  const sort = new Map();
261
410
  if (Array.isArray(sortProperties)) {
262
411
  for (const sortProperty of sortProperties) {
@@ -277,30 +426,117 @@ export class MongoDbEntityStorageConnector {
277
426
  ?.find(filter, { projection })
278
427
  .sort(sort)
279
428
  .skip(cursorValue)
280
- .limit(returnSize)
429
+ .limit(returnSize + 1)
281
430
  .toArray();
282
- const entities = entitiesResult ?? [];
283
- for (const entity of entities) {
431
+ const rawResults = entitiesResult ?? [];
432
+ const hasMore = rawResults.length > returnSize;
433
+ const entities = hasMore ? rawResults.slice(0, returnSize) : rawResults;
434
+ for (let i = 0; i < entities.length; i++) {
435
+ const entity = entities[i];
284
436
  ObjectHelper.propertyDelete(entity, "_id");
285
- ObjectHelper.propertyDelete(entity, MongoDbEntityStorageConnector._PARTITION_KEY);
437
+ entities[i] = EntityStorageHelper.unPrepareEntity(entity, [
438
+ MongoDbEntityStorageConnector._PARTITION_KEY
439
+ ]);
286
440
  }
287
441
  return {
288
442
  entities,
289
- cursor: entities?.length === returnSize ? String(cursorValue + returnSize) : undefined
443
+ cursor: hasMore ? String(cursorValue + returnSize) : undefined
290
444
  };
291
445
  }
292
446
  /**
293
- * Drop the collection.
294
- * @returns Nothing.
447
+ * Count all the entities which match the conditions.
448
+ * @param conditions The optional conditions to match for the entities.
449
+ * @returns The total count of entities in the storage.
450
+ */
451
+ async count(conditions) {
452
+ try {
453
+ const contextIds = await ContextIdStore.getContextIds();
454
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
455
+ const filter = this.buildFilter(conditions, partitionKey);
456
+ return await this._client
457
+ .db(this._config.database)
458
+ .collection(this._config.collection)
459
+ .countDocuments(filter);
460
+ }
461
+ catch (err) {
462
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
463
+ }
464
+ }
465
+ /**
466
+ * Get all unique partition context ids present in the collection.
467
+ * @returns An array of context id objects, one per unique partition.
295
468
  */
296
- async collectionDrop() {
469
+ async getPartitionContextIds() {
470
+ if (!Is.arrayValue(this._partitionContextIds)) {
471
+ return [];
472
+ }
297
473
  try {
298
474
  const collection = await this.getCollection();
299
- await collection.drop();
475
+ const partitionIds = await collection.distinct(MongoDbEntityStorageConnector._PARTITION_KEY);
476
+ return partitionIds
477
+ .filter((id) => Is.stringValue(id))
478
+ .map(partitionId => ContextIdHelper.shortSplit(this._partitionContextIds ?? [], partitionId));
300
479
  }
301
- catch {
302
- // Ignore errors
480
+ catch (err) {
481
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
482
+ }
483
+ }
484
+ /**
485
+ * Create the target connector for performing the migration using a temporary collection.
486
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
487
+ * @returns Connector for performing the migration.
488
+ */
489
+ async createTargetConnector(newEntitySchema) {
490
+ const migrationCollectionName = `${this._config.collection}Migration${Date.now()}`;
491
+ return new MongoDbEntityStorageConnector({
492
+ entitySchema: newEntitySchema,
493
+ config: {
494
+ ...this._config,
495
+ collection: migrationCollectionName
496
+ },
497
+ partitionContextIds: this._partitionContextIds
498
+ });
499
+ }
500
+ /**
501
+ * Finalize the migration by dropping the source collection and renaming the migration collection to the original name.
502
+ * @param targetConnector The connector holding the migrated data in a temporary collection.
503
+ * @param options The options to control how the migration is finalized.
504
+ * @param loggingComponentType The logging component type to use during finalization.
505
+ * @returns The final connector using the original collection name with the new schema.
506
+ */
507
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
508
+ // Only rename if the migration collection was actually created (it won't exist if no
509
+ // entities were written, since MongoDB creates collections lazily on first write).
510
+ const migrationCollection = await targetConnector.getCollection();
511
+ const collections = await targetConnector._client
512
+ .db(targetConnector._config.database)
513
+ .listCollections({ name: targetConnector._config.collection })
514
+ .toArray();
515
+ if (collections.length > 0) {
516
+ // Teardown the existing table with the original name to free up the name for the new table
517
+ await this.teardown(loggingComponentType);
518
+ await migrationCollection.rename(this._config.collection);
303
519
  }
520
+ const finalConnector = new MongoDbEntityStorageConnector({
521
+ entitySchema: targetConnector._entitySchemaName,
522
+ config: this._config,
523
+ partitionContextIds: this._partitionContextIds
524
+ });
525
+ if (await finalConnector.bootstrap(loggingComponentType)) {
526
+ await targetConnector.stop?.();
527
+ return finalConnector;
528
+ }
529
+ throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
530
+ }
531
+ /**
532
+ * Cleanup a failed or aborted migration by dropping the temporary migration collection.
533
+ * @param targetConnector The target connector to cleanup.
534
+ * @param options The options to control how the migration is cleaned up.
535
+ * @param loggingComponentType The optional component type to use for logging.
536
+ * @returns A promise that resolves when the cleanup is complete.
537
+ */
538
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
539
+ await targetConnector?.teardown?.(loggingComponentType);
304
540
  }
305
541
  /**
306
542
  * Create a new DB connection configuration.
@@ -324,6 +560,34 @@ export class MongoDbEntityStorageConnector {
324
560
  const { database, collection } = this._config;
325
561
  return this._client.db(database).collection(collection);
326
562
  }
563
+ /**
564
+ * Build a MongoDB filter combining partition key and optional conditions.
565
+ * @param conditions The optional entity conditions to include.
566
+ * @param partitionKey The partition key value.
567
+ * @returns The MongoDB filter object.
568
+ * @internal
569
+ */
570
+ buildFilter(conditions, partitionKey) {
571
+ const finalConditions = {
572
+ conditions: [],
573
+ logicalOperator: LogicalOperator.And
574
+ };
575
+ if (Is.stringValue(partitionKey)) {
576
+ finalConditions.conditions.push({
577
+ property: MongoDbEntityStorageConnector._PARTITION_KEY,
578
+ comparison: ComparisonOperator.Equals,
579
+ value: partitionKey
580
+ });
581
+ }
582
+ if (!Is.empty(conditions)) {
583
+ finalConditions.conditions.push(conditions);
584
+ }
585
+ const filter = {};
586
+ if (finalConditions.conditions.length > 0) {
587
+ this.buildQueryParameters("", finalConditions, filter);
588
+ }
589
+ return filter;
590
+ }
327
591
  /**
328
592
  * Create an MongoDB filter query.
329
593
  * @param objectPath The path for the nested object.
@@ -352,8 +616,17 @@ export class MongoDbEntityStorageConnector {
352
616
  }
353
617
  }
354
618
  else {
355
- const prop = objectPath ? `${objectPath}.${condition.property}` : String(condition.property);
356
- const comparison = this.mapComparisonOperator(condition.comparison, condition.value);
619
+ const propertyPath = String(condition.property);
620
+ const prop = objectPath ? `${objectPath}.${propertyPath}` : propertyPath;
621
+ const propertyParts = propertyPath.split(".");
622
+ const schemaLookupName = propertyParts.length > 1 ? propertyParts[0] : propertyPath;
623
+ const propertySchema = this._entitySchema.properties?.find(p => p.property === schemaLookupName);
624
+ // For dot-notation paths the leaf field is always a string value; using the root
625
+ // type directly would send Includes into $elemMatch which does not work for nested
626
+ // string fields. Keeping String here causes mapComparisonOperator to emit $regex,
627
+ // which MongoDB handles correctly for both nested object and array traversal.
628
+ const propertyType = propertyParts.length > 1 ? EntitySchemaPropertyType.String : propertySchema?.type;
629
+ const comparison = this.mapComparisonOperator(condition.comparison, condition.value, propertyType);
357
630
  filter[prop] = comparison;
358
631
  }
359
632
  }
@@ -361,10 +634,11 @@ export class MongoDbEntityStorageConnector {
361
634
  * Map the framework comparison operators to those in MongoDB.
362
635
  * @param comparison The comparison operator.
363
636
  * @param value The value to compare.
637
+ * @param type The type of the property from the schema.
364
638
  * @returns The MongoDB comparison expression.
365
639
  * @internal
366
640
  */
367
- mapComparisonOperator(comparison, value) {
641
+ mapComparisonOperator(comparison, value, type) {
368
642
  switch (comparison) {
369
643
  case ComparisonOperator.Equals:
370
644
  return value;
@@ -381,8 +655,25 @@ export class MongoDbEntityStorageConnector {
381
655
  case ComparisonOperator.In:
382
656
  return { $in: Array.isArray(value) ? value : [value] };
383
657
  case ComparisonOperator.Includes:
658
+ // For string fields, use regex for substring matching
659
+ if (type === EntitySchemaPropertyType.String) {
660
+ // Escape special regex characters in the value
661
+ const escapedValue = String(value).replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
662
+ return { $regex: escapedValue };
663
+ }
664
+ // For array and object fields, use $elemMatch
665
+ if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
666
+ return { $elemMatch: { $eq: value } };
667
+ }
668
+ // Fallback to $elemMatch for backwards compatibility
384
669
  return { $elemMatch: { $eq: value } };
385
670
  case ComparisonOperator.NotIncludes:
671
+ // For string fields, use negated regex
672
+ if (type === EntitySchemaPropertyType.String) {
673
+ const escapedValue = String(value).replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
674
+ return { $not: { $regex: escapedValue } };
675
+ }
676
+ // For arrays, use $elemMatch with $ne
386
677
  return { $elemMatch: { $ne: value } };
387
678
  default:
388
679
  throw new GeneralError(MongoDbEntityStorageConnector.CLASS_NAME, "unsupportedComparisonOperator", { comparison });