mongolite-ts 0.6.2 → 0.7.2
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 +46 -610
- package/dist/collection.d.ts +91 -4
- package/dist/collection.js +796 -6
- package/dist/collection.js.map +1 -1
- package/dist/cursors/findCursor.d.ts +17 -2
- package/dist/cursors/findCursor.js +136 -2
- package/dist/cursors/findCursor.js.map +1 -1
- package/dist/db.js +26 -0
- package/dist/db.js.map +1 -1
- package/dist/types.d.ts +109 -0
- package/package.json +7 -6
- package/dist/database-manager.d.ts +0 -1
- package/dist/database-manager.js +0 -2
- package/dist/database-manager.js.map +0 -1
- package/dist/document-utils.d.ts +0 -34
- package/dist/document-utils.js +0 -101
- package/dist/document-utils.js.map +0 -1
- package/dist/find-cursor.d.ts +0 -51
- package/dist/find-cursor.js +0 -204
- package/dist/find-cursor.js.map +0 -1
- package/dist/plugins/mongodbSync.d.ts +0 -128
- package/dist/plugins/mongodbSync.js +0 -339
- package/dist/plugins/mongodbSync.js.map +0 -1
- package/dist/query-builder.d.ts +0 -18
- package/dist/query-builder.js +0 -358
- package/dist/query-builder.js.map +0 -1
- package/dist/update-operations.d.ts +0 -17
- package/dist/update-operations.js +0 -147
- package/dist/update-operations.js.map +0 -1
package/dist/collection.js
CHANGED
|
@@ -218,22 +218,27 @@ export class MongoLiteCollection {
|
|
|
218
218
|
* If any document does not have an `_id`, a UUID will be generated.
|
|
219
219
|
* Uses batch insert with transactions for improved performance.
|
|
220
220
|
* @param docs An array of documents to insert.
|
|
221
|
-
* @returns {Promise<
|
|
221
|
+
* @returns {Promise<InsertManyResult>} An object containing the outcome of all insert operations.
|
|
222
222
|
* */
|
|
223
223
|
async insertMany(docs) {
|
|
224
224
|
await this.ensureTable(); // Ensure table exists before insert
|
|
225
225
|
if (docs.length === 0) {
|
|
226
|
-
return
|
|
226
|
+
return { acknowledged: true, insertedCount: 0, insertedIds: {} };
|
|
227
227
|
}
|
|
228
|
-
const
|
|
228
|
+
const insertedIds = {};
|
|
229
229
|
const batchSize = 500; // Process in batches to avoid memory issues with very large datasets
|
|
230
|
+
let insertedCount = 0;
|
|
231
|
+
let index = 0;
|
|
230
232
|
// Process documents in batches
|
|
231
233
|
for (let i = 0; i < docs.length; i += batchSize) {
|
|
232
234
|
const batch = docs.slice(i, i + batchSize);
|
|
233
235
|
const batchResults = await this.insertBatch(batch);
|
|
234
|
-
|
|
236
|
+
batchResults.forEach((res) => {
|
|
237
|
+
insertedIds[index++] = res.insertedId;
|
|
238
|
+
});
|
|
239
|
+
insertedCount += batchResults.length;
|
|
235
240
|
}
|
|
236
|
-
return
|
|
241
|
+
return { acknowledged: true, insertedCount, insertedIds };
|
|
237
242
|
}
|
|
238
243
|
/**
|
|
239
244
|
* Inserts a batch of documents using a single transaction for optimal performance.
|
|
@@ -310,6 +315,9 @@ export class MongoLiteCollection {
|
|
|
310
315
|
* @param filter The query criteria.
|
|
311
316
|
* @param projection Optional. Specifies the fields to return.
|
|
312
317
|
* @returns {Promise<T | null>} The found document or `null`.
|
|
318
|
+
* @remarks When using a projection, some fields may be undefined at runtime despite the
|
|
319
|
+
* return type being `T`. This provides better ergonomics for the common case where no
|
|
320
|
+
* projection is used.
|
|
313
321
|
*/
|
|
314
322
|
async findOne(filter, projection) {
|
|
315
323
|
await this.ensureTable();
|
|
@@ -463,6 +471,104 @@ export class MongoLiteCollection {
|
|
|
463
471
|
}
|
|
464
472
|
}
|
|
465
473
|
break;
|
|
474
|
+
case '$addToSet':
|
|
475
|
+
for (const path in opArgs) {
|
|
476
|
+
const value = opArgs[path];
|
|
477
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
478
|
+
const arr = Array.isArray(currentValue) ? currentValue : [];
|
|
479
|
+
const items = typeof value === 'object' && value !== null && '$each' in value && Array.isArray(value.$each)
|
|
480
|
+
? value.$each
|
|
481
|
+
: [value];
|
|
482
|
+
let changed = false;
|
|
483
|
+
for (const item of items) {
|
|
484
|
+
const alreadyPresent = arr.some((el) => JSON.stringify(el) === JSON.stringify(item));
|
|
485
|
+
if (!alreadyPresent) {
|
|
486
|
+
arr.push(item);
|
|
487
|
+
changed = true;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (changed || !Array.isArray(currentValue)) {
|
|
491
|
+
this.setNestedValue(currentDoc, path, arr);
|
|
492
|
+
modified = true;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
break;
|
|
496
|
+
case '$pop':
|
|
497
|
+
for (const path in opArgs) {
|
|
498
|
+
const value = opArgs[path];
|
|
499
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
500
|
+
if (Array.isArray(currentValue) && currentValue.length > 0) {
|
|
501
|
+
if (value === 1) {
|
|
502
|
+
currentValue.pop();
|
|
503
|
+
}
|
|
504
|
+
else if (value === -1) {
|
|
505
|
+
currentValue.shift();
|
|
506
|
+
}
|
|
507
|
+
this.setNestedValue(currentDoc, path, currentValue);
|
|
508
|
+
modified = true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
break;
|
|
512
|
+
case '$mul':
|
|
513
|
+
for (const path in opArgs) {
|
|
514
|
+
const value = opArgs[path];
|
|
515
|
+
if (typeof value === 'number') {
|
|
516
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
517
|
+
const numericCurrent = typeof currentValue === 'number' ? currentValue : 0;
|
|
518
|
+
this.setNestedValue(currentDoc, path, numericCurrent * value);
|
|
519
|
+
modified = true;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
break;
|
|
523
|
+
case '$min':
|
|
524
|
+
for (const path in opArgs) {
|
|
525
|
+
const value = opArgs[path];
|
|
526
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
527
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
528
|
+
if (currentValue === undefined || value < currentValue) {
|
|
529
|
+
this.setNestedValue(currentDoc, path, value);
|
|
530
|
+
modified = true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
break;
|
|
534
|
+
case '$max':
|
|
535
|
+
for (const path in opArgs) {
|
|
536
|
+
const value = opArgs[path];
|
|
537
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
538
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
539
|
+
if (currentValue === undefined || value > currentValue) {
|
|
540
|
+
this.setNestedValue(currentDoc, path, value);
|
|
541
|
+
modified = true;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
break;
|
|
545
|
+
case '$currentDate':
|
|
546
|
+
for (const path in opArgs) {
|
|
547
|
+
const value = opArgs[path];
|
|
548
|
+
if (value === true) {
|
|
549
|
+
// Default: store as ISO string date
|
|
550
|
+
this.setNestedValue(currentDoc, path, new Date().toISOString());
|
|
551
|
+
modified = true;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (value && typeof value === 'object' && '$type' in value) {
|
|
555
|
+
const typeValue = value.$type;
|
|
556
|
+
if (typeValue === 'date') {
|
|
557
|
+
this.setNestedValue(currentDoc, path, new Date().toISOString());
|
|
558
|
+
modified = true;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (typeValue === 'timestamp') {
|
|
562
|
+
// Represent timestamp as a numeric UNIX epoch in milliseconds
|
|
563
|
+
this.setNestedValue(currentDoc, path, Date.now());
|
|
564
|
+
modified = true;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
throw new Error(`Unsupported $currentDate $type value for path "${path}": ${JSON.stringify(typeValue)}`);
|
|
568
|
+
}
|
|
569
|
+
throw new Error(`Unsupported $currentDate value for path "${path}": ${JSON.stringify(value)}`);
|
|
570
|
+
}
|
|
571
|
+
break;
|
|
466
572
|
}
|
|
467
573
|
}
|
|
468
574
|
if (modified) {
|
|
@@ -599,7 +705,83 @@ export class MongoLiteCollection {
|
|
|
599
705
|
}
|
|
600
706
|
}
|
|
601
707
|
break;
|
|
602
|
-
|
|
708
|
+
case '$addToSet':
|
|
709
|
+
for (const path in opArgs) {
|
|
710
|
+
const value = opArgs[path];
|
|
711
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
712
|
+
const arr = Array.isArray(currentValue) ? currentValue : [];
|
|
713
|
+
const items = typeof value === 'object' && value !== null && '$each' in value && Array.isArray(value.$each)
|
|
714
|
+
? value.$each
|
|
715
|
+
: [value];
|
|
716
|
+
let changed = false;
|
|
717
|
+
for (const item of items) {
|
|
718
|
+
const alreadyPresent = arr.some((el) => JSON.stringify(el) === JSON.stringify(item));
|
|
719
|
+
if (!alreadyPresent) {
|
|
720
|
+
arr.push(item);
|
|
721
|
+
changed = true;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (changed || !Array.isArray(currentValue)) {
|
|
725
|
+
this.setNestedValue(currentDoc, path, arr);
|
|
726
|
+
modified = true;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
break;
|
|
730
|
+
case '$pop':
|
|
731
|
+
for (const path in opArgs) {
|
|
732
|
+
const value = opArgs[path];
|
|
733
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
734
|
+
if (Array.isArray(currentValue) && currentValue.length > 0) {
|
|
735
|
+
if (value === 1) {
|
|
736
|
+
currentValue.pop();
|
|
737
|
+
}
|
|
738
|
+
else if (value === -1) {
|
|
739
|
+
currentValue.shift();
|
|
740
|
+
}
|
|
741
|
+
this.setNestedValue(currentDoc, path, currentValue);
|
|
742
|
+
modified = true;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
break;
|
|
746
|
+
case '$mul':
|
|
747
|
+
for (const path in opArgs) {
|
|
748
|
+
const value = opArgs[path];
|
|
749
|
+
if (typeof value === 'number') {
|
|
750
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
751
|
+
const numericCurrent = typeof currentValue === 'number' ? currentValue : 0;
|
|
752
|
+
this.setNestedValue(currentDoc, path, numericCurrent * value);
|
|
753
|
+
modified = true;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
break;
|
|
757
|
+
case '$min':
|
|
758
|
+
for (const path in opArgs) {
|
|
759
|
+
const value = opArgs[path];
|
|
760
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
761
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
762
|
+
if (currentValue === undefined || value < currentValue) {
|
|
763
|
+
this.setNestedValue(currentDoc, path, value);
|
|
764
|
+
modified = true;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
break;
|
|
768
|
+
case '$max':
|
|
769
|
+
for (const path in opArgs) {
|
|
770
|
+
const value = opArgs[path];
|
|
771
|
+
const currentValue = this.getNestedValue(currentDoc, path);
|
|
772
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
773
|
+
if (currentValue === undefined || value > currentValue) {
|
|
774
|
+
this.setNestedValue(currentDoc, path, value);
|
|
775
|
+
modified = true;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
break;
|
|
779
|
+
case '$currentDate':
|
|
780
|
+
for (const path in opArgs) {
|
|
781
|
+
this.setNestedValue(currentDoc, path, new Date().toISOString());
|
|
782
|
+
modified = true;
|
|
783
|
+
}
|
|
784
|
+
break;
|
|
603
785
|
default:
|
|
604
786
|
throw new Error(`Unsupported update operator: ${operator}`);
|
|
605
787
|
}
|
|
@@ -912,6 +1094,614 @@ export class MongoLiteCollection {
|
|
|
912
1094
|
const result = await this.db.get(countSql, paramsForCount);
|
|
913
1095
|
return result?.count || 0;
|
|
914
1096
|
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Returns the number of documents in the collection.
|
|
1099
|
+
* This is an estimate and may not reflect the exact count in concurrent environments.
|
|
1100
|
+
* @returns {Promise<number>} The estimated count.
|
|
1101
|
+
*/
|
|
1102
|
+
async estimatedDocumentCount() {
|
|
1103
|
+
await this.ensureTable();
|
|
1104
|
+
const result = await this.db.get(`SELECT COUNT(*) as count FROM "${this.name}"`);
|
|
1105
|
+
return result?.count || 0;
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Finds a single document matching the filter, applies the update, and returns the document.
|
|
1109
|
+
* @param filter The selection criteria.
|
|
1110
|
+
* @param update The modifications to apply.
|
|
1111
|
+
* @param options Options including returnDocument ('before'|'after') and upsert.
|
|
1112
|
+
* @returns {Promise<T | null>} The document before or after the update, or null.
|
|
1113
|
+
*/
|
|
1114
|
+
async findOneAndUpdate(filter, update, options = {}) {
|
|
1115
|
+
await this.ensureTable();
|
|
1116
|
+
const { returnDocument = 'before', upsert = false, projection } = options;
|
|
1117
|
+
// Fetch the target document without projection so we always have the _id
|
|
1118
|
+
const idDocs = await this.find(filter).limit(1).toArray();
|
|
1119
|
+
const existingFullDoc = idDocs.length > 0 ? idDocs[0] : null;
|
|
1120
|
+
const existingId = existingFullDoc?._id;
|
|
1121
|
+
if (!existingFullDoc && !upsert) {
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
// Update by _id when available to ensure we modify the exact document we found
|
|
1125
|
+
const updateFilter = existingId !== undefined && existingId !== null
|
|
1126
|
+
? { _id: existingId }
|
|
1127
|
+
: filter;
|
|
1128
|
+
const updateResult = await this.updateOne(updateFilter, update, { upsert });
|
|
1129
|
+
if (returnDocument === 'after') {
|
|
1130
|
+
const targetId = updateResult.upsertedId ?? existingId;
|
|
1131
|
+
if (!targetId)
|
|
1132
|
+
return null;
|
|
1133
|
+
const afterCursor = this.find({ _id: targetId }).limit(1);
|
|
1134
|
+
if (projection)
|
|
1135
|
+
afterCursor.project(projection);
|
|
1136
|
+
const afterDocs = await afterCursor.toArray();
|
|
1137
|
+
return afterDocs.length > 0 ? afterDocs[0] : null;
|
|
1138
|
+
}
|
|
1139
|
+
// returnDocument === 'before'
|
|
1140
|
+
if (!existingFullDoc) {
|
|
1141
|
+
// Upsert case where no original document existed
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
if (!projection) {
|
|
1145
|
+
return existingFullDoc;
|
|
1146
|
+
}
|
|
1147
|
+
// Apply projection to the located document by _id
|
|
1148
|
+
if (existingId === undefined || existingId === null) {
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
const beforeCursor = this.find({ _id: existingId }).limit(1);
|
|
1152
|
+
beforeCursor.project(projection);
|
|
1153
|
+
const beforeDocs = await beforeCursor.toArray();
|
|
1154
|
+
return beforeDocs.length > 0 ? beforeDocs[0] : null;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Finds a single document matching the filter, deletes it, and returns it.
|
|
1158
|
+
* @param filter The selection criteria.
|
|
1159
|
+
* @param options Options including field projection.
|
|
1160
|
+
* @returns {Promise<T | null>} The deleted document or null.
|
|
1161
|
+
*/
|
|
1162
|
+
async findOneAndDelete(filter, options = {}) {
|
|
1163
|
+
await this.ensureTable();
|
|
1164
|
+
const { projection } = options;
|
|
1165
|
+
// Fetch without projection to always have _id for the delete
|
|
1166
|
+
const idDocs = await this.find(filter).limit(1).toArray();
|
|
1167
|
+
const existingFullDoc = idDocs.length > 0 ? idDocs[0] : null;
|
|
1168
|
+
if (!existingFullDoc) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
await this.deleteOne({ _id: existingFullDoc._id });
|
|
1172
|
+
if (!projection) {
|
|
1173
|
+
return existingFullDoc;
|
|
1174
|
+
}
|
|
1175
|
+
// Apply projection to the in-memory document
|
|
1176
|
+
const projCursor = this.find({ _id: existingFullDoc._id }).limit(1);
|
|
1177
|
+
projCursor.project(projection);
|
|
1178
|
+
// Since we already deleted, apply projection manually from the full doc
|
|
1179
|
+
const fullDocRec = existingFullDoc;
|
|
1180
|
+
return this.applyAggregateProjection(fullDocRec, projection);
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Finds a single document matching the filter and replaces it entirely.
|
|
1184
|
+
* @param filter The selection criteria.
|
|
1185
|
+
* @param replacement The replacement document (replaces all fields except _id).
|
|
1186
|
+
* @param options Options including returnDocument, upsert, and projection.
|
|
1187
|
+
* @returns {Promise<T | null>} The document before or after the replacement, or null.
|
|
1188
|
+
*/
|
|
1189
|
+
async findOneAndReplace(filter, replacement, options = {}) {
|
|
1190
|
+
await this.ensureTable();
|
|
1191
|
+
const { returnDocument = 'before', upsert = false, projection } = options;
|
|
1192
|
+
const existingDoc = await this.findOne(filter);
|
|
1193
|
+
if (!existingDoc && !upsert) {
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
if (existingDoc) {
|
|
1197
|
+
const docId = existingDoc._id;
|
|
1198
|
+
const newData = safeJsonStringify(replacement, `findOneAndReplace for ${docId}`);
|
|
1199
|
+
await this.db.run(`UPDATE "${this.name}" SET data = ? WHERE _id = ?`, [newData, docId]);
|
|
1200
|
+
if (returnDocument === 'after') {
|
|
1201
|
+
const afterCursor = this.find({ _id: docId }).limit(1);
|
|
1202
|
+
if (projection)
|
|
1203
|
+
afterCursor.project(projection);
|
|
1204
|
+
const afterDocs = await afterCursor.toArray();
|
|
1205
|
+
return afterDocs.length > 0 ? afterDocs[0] : null;
|
|
1206
|
+
}
|
|
1207
|
+
if (!projection) {
|
|
1208
|
+
return existingDoc;
|
|
1209
|
+
}
|
|
1210
|
+
// Apply projection to the pre-replacement document
|
|
1211
|
+
return this.applyAggregateProjection(existingDoc, projection);
|
|
1212
|
+
}
|
|
1213
|
+
else {
|
|
1214
|
+
// Upsert: insert the replacement. Carry _id from filter if it's a simple equality.
|
|
1215
|
+
const filterRec = filter;
|
|
1216
|
+
const filterId = filterRec['_id'] !== undefined &&
|
|
1217
|
+
filterRec['_id'] !== null &&
|
|
1218
|
+
typeof filterRec['_id'] !== 'object'
|
|
1219
|
+
? filterRec['_id']
|
|
1220
|
+
: undefined;
|
|
1221
|
+
const docToInsert = filterId
|
|
1222
|
+
? { ...replacement, _id: filterId }
|
|
1223
|
+
: replacement;
|
|
1224
|
+
const result = await this.insertOne(docToInsert);
|
|
1225
|
+
if (returnDocument === 'after') {
|
|
1226
|
+
const afterCursor = this.find({ _id: result.insertedId }).limit(1);
|
|
1227
|
+
if (projection)
|
|
1228
|
+
afterCursor.project(projection);
|
|
1229
|
+
const afterDocs = await afterCursor.toArray();
|
|
1230
|
+
return afterDocs.length > 0 ? afterDocs[0] : null;
|
|
1231
|
+
}
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Replaces a single document matching the filter.
|
|
1237
|
+
* @param filter The selection criteria.
|
|
1238
|
+
* @param replacement The replacement document (replaces all fields except _id).
|
|
1239
|
+
* @param options Options including upsert.
|
|
1240
|
+
* @returns {Promise<UpdateResult>} An object describing the outcome.
|
|
1241
|
+
*/
|
|
1242
|
+
async replaceOne(filter, replacement, options = {}) {
|
|
1243
|
+
await this.ensureTable();
|
|
1244
|
+
const { upsert = false } = options;
|
|
1245
|
+
const existingDoc = await this.findOne(filter);
|
|
1246
|
+
if (!existingDoc) {
|
|
1247
|
+
if (!upsert) {
|
|
1248
|
+
return { acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedId: null };
|
|
1249
|
+
}
|
|
1250
|
+
const result = await this.insertOne(replacement);
|
|
1251
|
+
return {
|
|
1252
|
+
acknowledged: true,
|
|
1253
|
+
matchedCount: 0,
|
|
1254
|
+
modifiedCount: 0,
|
|
1255
|
+
upsertedId: result.insertedId,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
const docId = existingDoc._id;
|
|
1259
|
+
const newData = safeJsonStringify(replacement, `replaceOne for ${docId}`);
|
|
1260
|
+
await this.db.run(`UPDATE "${this.name}" SET data = ? WHERE _id = ?`, [newData, docId]);
|
|
1261
|
+
return { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null };
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Returns distinct values for a field across all documents matching the filter.
|
|
1265
|
+
* @param field The field to find distinct values for.
|
|
1266
|
+
* @param filter Optional filter to narrow the documents.
|
|
1267
|
+
* @returns {Promise<unknown[]>} An array of distinct values.
|
|
1268
|
+
*/
|
|
1269
|
+
async distinct(field, filter = {}) {
|
|
1270
|
+
await this.ensureTable();
|
|
1271
|
+
const docs = await this.find(filter).toArray();
|
|
1272
|
+
const seen = new Set();
|
|
1273
|
+
const results = [];
|
|
1274
|
+
for (const doc of docs) {
|
|
1275
|
+
const value = this.getNestedValue(doc, field);
|
|
1276
|
+
if (Array.isArray(value)) {
|
|
1277
|
+
for (const item of value) {
|
|
1278
|
+
const key = JSON.stringify(item);
|
|
1279
|
+
if (!seen.has(key)) {
|
|
1280
|
+
seen.add(key);
|
|
1281
|
+
results.push(item);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
else if (value !== undefined) {
|
|
1286
|
+
const key = JSON.stringify(value);
|
|
1287
|
+
if (!seen.has(key)) {
|
|
1288
|
+
seen.add(key);
|
|
1289
|
+
results.push(value);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return results;
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Drops the entire collection (table) from the database.
|
|
1297
|
+
* @returns {Promise<void>}
|
|
1298
|
+
*/
|
|
1299
|
+
async drop() {
|
|
1300
|
+
await this.db.exec(`DROP TABLE IF EXISTS "${this.name}"`);
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Executes an aggregation pipeline on the collection.
|
|
1304
|
+
* Supports: $match, $project, $sort, $limit, $skip, $count, $group, $unwind, $addFields.
|
|
1305
|
+
* @param pipeline An array of pipeline stage documents.
|
|
1306
|
+
* @returns An object with a toArray() method that returns the aggregation result.
|
|
1307
|
+
*/
|
|
1308
|
+
aggregate(pipeline) {
|
|
1309
|
+
return {
|
|
1310
|
+
toArray: async () => {
|
|
1311
|
+
await this.ensureTable();
|
|
1312
|
+
return this.runAggregationPipeline(pipeline);
|
|
1313
|
+
},
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Runs an aggregation pipeline and returns results.
|
|
1318
|
+
* @private
|
|
1319
|
+
*/
|
|
1320
|
+
async runAggregationPipeline(pipeline) {
|
|
1321
|
+
let results = [];
|
|
1322
|
+
// Only push the initial $match down to SQL when it is the very first stage.
|
|
1323
|
+
// Applying a $match that appears after other stages (e.g. after $unwind/$group) too
|
|
1324
|
+
// early would change pipeline semantics.
|
|
1325
|
+
const firstStage = pipeline[0];
|
|
1326
|
+
const firstIsMatch = firstStage !== undefined && '$match' in firstStage;
|
|
1327
|
+
if (firstIsMatch) {
|
|
1328
|
+
const matchFilter = firstStage.$match;
|
|
1329
|
+
const docs = await this.find(matchFilter).toArray();
|
|
1330
|
+
results = docs;
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
const docs = await this.find({}).toArray();
|
|
1334
|
+
results = docs;
|
|
1335
|
+
}
|
|
1336
|
+
for (let i = 0; i < pipeline.length; i++) {
|
|
1337
|
+
const stage = pipeline[i];
|
|
1338
|
+
const stageKey = Object.keys(stage)[0];
|
|
1339
|
+
switch (stageKey) {
|
|
1340
|
+
case '$match': {
|
|
1341
|
+
// Skip the first stage if it was already used for the initial SQL fetch
|
|
1342
|
+
if (i === 0 && firstIsMatch) {
|
|
1343
|
+
break;
|
|
1344
|
+
}
|
|
1345
|
+
const matchFilter = stage['$match'];
|
|
1346
|
+
results = results.filter((doc) => this.matchesFilter(doc, matchFilter));
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
case '$project': {
|
|
1350
|
+
const projection = stage['$project'];
|
|
1351
|
+
results = results.map((doc) => this.applyAggregateProjection(doc, projection));
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
case '$addFields': {
|
|
1355
|
+
const fields = stage['$addFields'];
|
|
1356
|
+
results = results.map((doc) => ({ ...doc, ...fields }));
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
case '$sort': {
|
|
1360
|
+
const sortSpec = stage['$sort'];
|
|
1361
|
+
results = results.sort((a, b) => {
|
|
1362
|
+
for (const [field, order] of Object.entries(sortSpec)) {
|
|
1363
|
+
const aVal = this.getNestedValue(a, field);
|
|
1364
|
+
const bVal = this.getNestedValue(b, field);
|
|
1365
|
+
if (aVal === bVal)
|
|
1366
|
+
continue;
|
|
1367
|
+
if (aVal === null || aVal === undefined)
|
|
1368
|
+
return order;
|
|
1369
|
+
if (bVal === null || bVal === undefined)
|
|
1370
|
+
return -order;
|
|
1371
|
+
const cmp = aVal < bVal ? -1 : 1;
|
|
1372
|
+
return cmp * order;
|
|
1373
|
+
}
|
|
1374
|
+
return 0;
|
|
1375
|
+
});
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
case '$limit': {
|
|
1379
|
+
const limitCount = stage['$limit'];
|
|
1380
|
+
results = results.slice(0, limitCount);
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
case '$skip': {
|
|
1384
|
+
const skipCount = stage['$skip'];
|
|
1385
|
+
results = results.slice(skipCount);
|
|
1386
|
+
break;
|
|
1387
|
+
}
|
|
1388
|
+
case '$count': {
|
|
1389
|
+
const countField = stage['$count'];
|
|
1390
|
+
results = [{ [countField]: results.length }];
|
|
1391
|
+
break;
|
|
1392
|
+
}
|
|
1393
|
+
case '$group': {
|
|
1394
|
+
results = this.applyGroupStage(results, stage['$group']);
|
|
1395
|
+
break;
|
|
1396
|
+
}
|
|
1397
|
+
case '$unwind': {
|
|
1398
|
+
const path = typeof stage['$unwind'] === 'string'
|
|
1399
|
+
? stage['$unwind']
|
|
1400
|
+
: stage['$unwind'].path;
|
|
1401
|
+
const fieldName = path.startsWith('$') ? path.slice(1) : path;
|
|
1402
|
+
const unwound = [];
|
|
1403
|
+
for (const doc of results) {
|
|
1404
|
+
const arrayVal = this.getNestedValue(doc, fieldName);
|
|
1405
|
+
if (Array.isArray(arrayVal)) {
|
|
1406
|
+
for (const item of arrayVal) {
|
|
1407
|
+
const newDoc = { ...doc };
|
|
1408
|
+
this.setNestedValue(newDoc, fieldName, item);
|
|
1409
|
+
unwound.push(newDoc);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
else if (arrayVal !== undefined && arrayVal !== null) {
|
|
1413
|
+
unwound.push(doc);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
results = unwound;
|
|
1417
|
+
break;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return results;
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Applies a $group pipeline stage.
|
|
1425
|
+
* @private
|
|
1426
|
+
*/
|
|
1427
|
+
applyGroupStage(docs, groupSpec) {
|
|
1428
|
+
const groups = new Map();
|
|
1429
|
+
const idSpec = groupSpec['_id'];
|
|
1430
|
+
for (const doc of docs) {
|
|
1431
|
+
let groupKey;
|
|
1432
|
+
if (idSpec === null) {
|
|
1433
|
+
groupKey = null;
|
|
1434
|
+
}
|
|
1435
|
+
else if (typeof idSpec === 'string' && idSpec.startsWith('$')) {
|
|
1436
|
+
groupKey = this.getNestedValue(doc, idSpec.slice(1));
|
|
1437
|
+
}
|
|
1438
|
+
else if (typeof idSpec === 'object' && idSpec !== null) {
|
|
1439
|
+
const keyObj = {};
|
|
1440
|
+
for (const [k, expr] of Object.entries(idSpec)) {
|
|
1441
|
+
if (typeof expr === 'string' && expr.startsWith('$')) {
|
|
1442
|
+
keyObj[k] = this.getNestedValue(doc, expr.slice(1));
|
|
1443
|
+
}
|
|
1444
|
+
else {
|
|
1445
|
+
keyObj[k] = expr;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
groupKey = keyObj;
|
|
1449
|
+
}
|
|
1450
|
+
else {
|
|
1451
|
+
groupKey = idSpec;
|
|
1452
|
+
}
|
|
1453
|
+
const keyStr = JSON.stringify(groupKey);
|
|
1454
|
+
if (!groups.has(keyStr)) {
|
|
1455
|
+
groups.set(keyStr, []);
|
|
1456
|
+
}
|
|
1457
|
+
groups.get(keyStr).push(doc);
|
|
1458
|
+
}
|
|
1459
|
+
const results = [];
|
|
1460
|
+
for (const [keyStr, groupDocs] of groups) {
|
|
1461
|
+
const groupResult = { _id: JSON.parse(keyStr) };
|
|
1462
|
+
for (const [field, expr] of Object.entries(groupSpec)) {
|
|
1463
|
+
if (field === '_id')
|
|
1464
|
+
continue;
|
|
1465
|
+
if (typeof expr === 'object' && expr !== null) {
|
|
1466
|
+
const aggOp = Object.keys(expr)[0];
|
|
1467
|
+
const aggArg = expr[aggOp];
|
|
1468
|
+
switch (aggOp) {
|
|
1469
|
+
case '$sum': {
|
|
1470
|
+
if (typeof aggArg === 'number') {
|
|
1471
|
+
groupResult[field] = groupDocs.length * aggArg;
|
|
1472
|
+
}
|
|
1473
|
+
else if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1474
|
+
const fieldName = aggArg.slice(1);
|
|
1475
|
+
groupResult[field] = groupDocs.reduce((sum, doc) => {
|
|
1476
|
+
const val = this.getNestedValue(doc, fieldName);
|
|
1477
|
+
return sum + (typeof val === 'number' ? val : 0);
|
|
1478
|
+
}, 0);
|
|
1479
|
+
}
|
|
1480
|
+
break;
|
|
1481
|
+
}
|
|
1482
|
+
case '$avg': {
|
|
1483
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1484
|
+
const fieldName = aggArg.slice(1);
|
|
1485
|
+
const nums = groupDocs
|
|
1486
|
+
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1487
|
+
.filter((v) => typeof v === 'number');
|
|
1488
|
+
groupResult[field] = nums.length > 0 ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
|
|
1489
|
+
}
|
|
1490
|
+
break;
|
|
1491
|
+
}
|
|
1492
|
+
case '$min': {
|
|
1493
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1494
|
+
const fieldName = aggArg.slice(1);
|
|
1495
|
+
const vals = groupDocs
|
|
1496
|
+
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1497
|
+
.filter((v) => v !== undefined && v !== null);
|
|
1498
|
+
groupResult[field] = vals.length > 0 ? vals.reduce((a, b) => (a < b ? a : b)) : null;
|
|
1499
|
+
}
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1502
|
+
case '$max': {
|
|
1503
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1504
|
+
const fieldName = aggArg.slice(1);
|
|
1505
|
+
const vals = groupDocs
|
|
1506
|
+
.map((doc) => this.getNestedValue(doc, fieldName))
|
|
1507
|
+
.filter((v) => v !== undefined && v !== null);
|
|
1508
|
+
groupResult[field] = vals.length > 0 ? vals.reduce((a, b) => (a > b ? a : b)) : null;
|
|
1509
|
+
}
|
|
1510
|
+
break;
|
|
1511
|
+
}
|
|
1512
|
+
case '$push': {
|
|
1513
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1514
|
+
const fieldName = aggArg.slice(1);
|
|
1515
|
+
groupResult[field] = groupDocs.map((doc) => this.getNestedValue(doc, fieldName));
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
groupResult[field] = groupDocs.map(() => aggArg);
|
|
1519
|
+
}
|
|
1520
|
+
break;
|
|
1521
|
+
}
|
|
1522
|
+
case '$first': {
|
|
1523
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1524
|
+
const fieldName = aggArg.slice(1);
|
|
1525
|
+
groupResult[field] =
|
|
1526
|
+
groupDocs.length > 0 ? this.getNestedValue(groupDocs[0], fieldName) : null;
|
|
1527
|
+
}
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
case '$last': {
|
|
1531
|
+
if (typeof aggArg === 'string' && aggArg.startsWith('$')) {
|
|
1532
|
+
const fieldName = aggArg.slice(1);
|
|
1533
|
+
groupResult[field] =
|
|
1534
|
+
groupDocs.length > 0
|
|
1535
|
+
? this.getNestedValue(groupDocs[groupDocs.length - 1], fieldName)
|
|
1536
|
+
: null;
|
|
1537
|
+
}
|
|
1538
|
+
break;
|
|
1539
|
+
}
|
|
1540
|
+
case '$count': {
|
|
1541
|
+
groupResult[field] = groupDocs.length;
|
|
1542
|
+
break;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
results.push(groupResult);
|
|
1548
|
+
}
|
|
1549
|
+
return results;
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Applies a $project stage to a document.
|
|
1553
|
+
* @private
|
|
1554
|
+
*/
|
|
1555
|
+
applyAggregateProjection(doc, projection) {
|
|
1556
|
+
const hasInclusion = Object.entries(projection).some(([k, v]) => k !== '_id' && (v === 1 || v === true));
|
|
1557
|
+
if (hasInclusion) {
|
|
1558
|
+
// Inclusion mode
|
|
1559
|
+
const result = {};
|
|
1560
|
+
if (projection['_id'] !== 0 && projection['_id'] !== false) {
|
|
1561
|
+
result['_id'] = doc['_id'];
|
|
1562
|
+
}
|
|
1563
|
+
for (const [field, val] of Object.entries(projection)) {
|
|
1564
|
+
if (val === 1 || val === true) {
|
|
1565
|
+
result[field] = this.getNestedValue(doc, field);
|
|
1566
|
+
}
|
|
1567
|
+
else if (typeof val === 'string' && val.startsWith('$')) {
|
|
1568
|
+
result[field] = this.getNestedValue(doc, val.slice(1));
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
return result;
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
// Exclusion mode
|
|
1575
|
+
const result = { ...doc };
|
|
1576
|
+
for (const [field, val] of Object.entries(projection)) {
|
|
1577
|
+
if (val === 0 || val === false) {
|
|
1578
|
+
delete result[field];
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
return result;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* Checks if a document matches a filter (for in-memory aggregation stages).
|
|
1586
|
+
* Uses the same logic as FindCursor but applied in JavaScript.
|
|
1587
|
+
* @private
|
|
1588
|
+
*/
|
|
1589
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1590
|
+
matchesFilter(doc, filter) {
|
|
1591
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
1592
|
+
if (key === '$and' && Array.isArray(value)) {
|
|
1593
|
+
if (!value.every((f) => this.matchesFilter(doc, f)))
|
|
1594
|
+
return false;
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
if (key === '$or' && Array.isArray(value)) {
|
|
1598
|
+
if (!value.some((f) => this.matchesFilter(doc, f)))
|
|
1599
|
+
return false;
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
if (key === '$nor' && Array.isArray(value)) {
|
|
1603
|
+
if (value.some((f) => this.matchesFilter(doc, f)))
|
|
1604
|
+
return false;
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
if (key.startsWith('$'))
|
|
1608
|
+
continue;
|
|
1609
|
+
const docValue = this.getNestedValue(doc, key);
|
|
1610
|
+
if (typeof value === 'object' && value !== null && !(value instanceof RegExp) && !(value instanceof Date)) {
|
|
1611
|
+
const ops = value;
|
|
1612
|
+
const hasOps = Object.keys(ops).some((k) => k.startsWith('$'));
|
|
1613
|
+
if (hasOps) {
|
|
1614
|
+
if (!this.matchesOperators(docValue, ops))
|
|
1615
|
+
return false;
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
// Simple equality (including Date comparison)
|
|
1620
|
+
if (value instanceof Date) {
|
|
1621
|
+
if (!(docValue instanceof Date) || docValue.getTime() !== value.getTime())
|
|
1622
|
+
return false;
|
|
1623
|
+
}
|
|
1624
|
+
else if (Array.isArray(docValue)) {
|
|
1625
|
+
if (!docValue.some((el) => JSON.stringify(el) === JSON.stringify(value)))
|
|
1626
|
+
return false;
|
|
1627
|
+
}
|
|
1628
|
+
else if (JSON.stringify(docValue) !== JSON.stringify(value)) {
|
|
1629
|
+
return false;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
return true;
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Checks if a value matches a set of query operators (for in-memory filtering).
|
|
1636
|
+
* @private
|
|
1637
|
+
*/
|
|
1638
|
+
matchesOperators(docValue, ops) {
|
|
1639
|
+
for (const [op, opVal] of Object.entries(ops)) {
|
|
1640
|
+
switch (op) {
|
|
1641
|
+
case '$eq':
|
|
1642
|
+
if (JSON.stringify(docValue) !== JSON.stringify(opVal))
|
|
1643
|
+
return false;
|
|
1644
|
+
break;
|
|
1645
|
+
case '$ne':
|
|
1646
|
+
if (JSON.stringify(docValue) === JSON.stringify(opVal))
|
|
1647
|
+
return false;
|
|
1648
|
+
break;
|
|
1649
|
+
case '$gt':
|
|
1650
|
+
if (!(docValue > opVal))
|
|
1651
|
+
return false;
|
|
1652
|
+
break;
|
|
1653
|
+
case '$gte':
|
|
1654
|
+
if (!(docValue >= opVal))
|
|
1655
|
+
return false;
|
|
1656
|
+
break;
|
|
1657
|
+
case '$lt':
|
|
1658
|
+
if (!(docValue < opVal))
|
|
1659
|
+
return false;
|
|
1660
|
+
break;
|
|
1661
|
+
case '$lte':
|
|
1662
|
+
if (!(docValue <= opVal))
|
|
1663
|
+
return false;
|
|
1664
|
+
break;
|
|
1665
|
+
case '$in':
|
|
1666
|
+
if (!Array.isArray(opVal))
|
|
1667
|
+
return false;
|
|
1668
|
+
if (Array.isArray(docValue)) {
|
|
1669
|
+
if (!docValue.some((el) => opVal.some((v) => JSON.stringify(el) === JSON.stringify(v))))
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
else {
|
|
1673
|
+
if (!opVal.some((v) => JSON.stringify(v) === JSON.stringify(docValue)))
|
|
1674
|
+
return false;
|
|
1675
|
+
}
|
|
1676
|
+
break;
|
|
1677
|
+
case '$nin':
|
|
1678
|
+
if (!Array.isArray(opVal))
|
|
1679
|
+
return false;
|
|
1680
|
+
if (opVal.some((v) => JSON.stringify(v) === JSON.stringify(docValue)))
|
|
1681
|
+
return false;
|
|
1682
|
+
break;
|
|
1683
|
+
case '$exists':
|
|
1684
|
+
if (opVal && docValue === undefined)
|
|
1685
|
+
return false;
|
|
1686
|
+
if (!opVal && docValue !== undefined)
|
|
1687
|
+
return false;
|
|
1688
|
+
break;
|
|
1689
|
+
case '$regex': {
|
|
1690
|
+
const pattern = opVal instanceof RegExp ? opVal : new RegExp(opVal);
|
|
1691
|
+
if (!pattern.test(String(docValue)))
|
|
1692
|
+
return false;
|
|
1693
|
+
break;
|
|
1694
|
+
}
|
|
1695
|
+
case '$size':
|
|
1696
|
+
if (!Array.isArray(docValue) || docValue.length !== opVal)
|
|
1697
|
+
return false;
|
|
1698
|
+
break;
|
|
1699
|
+
case '$options':
|
|
1700
|
+
break; // consumed by $regex
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
return true;
|
|
1704
|
+
}
|
|
915
1705
|
/**
|
|
916
1706
|
* Opens a change stream to watch for changes on this collection.
|
|
917
1707
|
* Returns a ChangeStream that emits events when documents are inserted, updated, or deleted.
|