adapt-authoring-mongodb 3.0.0 → 3.1.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.
@@ -1,4 +1,4 @@
1
- name: Standard.js formatting check
1
+ name: Lint
2
2
  on: push
3
3
  jobs:
4
4
  default:
@@ -9,4 +9,4 @@ jobs:
9
9
  with:
10
10
  node-version: 'lts/*'
11
11
  - run: npm install
12
- - run: npx standard
12
+ - run: npx standard
@@ -1,6 +1,7 @@
1
1
  import { AbstractModule } from 'adapt-authoring-core'
2
2
  import { MongoClient } from 'mongodb'
3
3
  import { convertObjectIds } from './utils/convertObjectIds.js'
4
+ import { findDuplicates } from './utils/findDuplicates.js'
4
5
  import { isValidObjectId } from './utils/isValidObjectId.js'
5
6
  import { parseObjectId } from './utils/parseObjectId.js'
6
7
  /**
@@ -67,7 +68,29 @@ class MongoDBModule extends AbstractModule {
67
68
  try {
68
69
  await this.getCollection(collectionName).createIndex(fieldOrSpec, options)
69
70
  } catch (e) {
70
- this.log('warn', e.toString())
71
+ if (e.code === 11000) {
72
+ await this.logDuplicateIndexError(collectionName, fieldOrSpec, e)
73
+ } else {
74
+ this.log('warn', e.toString())
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Logs detailed information about duplicate index errors
81
+ * @param {String} collectionName The name of the MongoDB collection
82
+ * @param {String|Array|Object} fieldOrSpec Definition of the index
83
+ * @param {Error} error The original MongoDB error
84
+ */
85
+ async logDuplicateIndexError (collectionName, fieldOrSpec, error) {
86
+ this.log('warn', `Duplicate index error on '${collectionName}': ${error.message}`)
87
+ try {
88
+ const duplicates = await findDuplicates(this.getCollection(collectionName), fieldOrSpec)
89
+ for (const { keyValue, _ids } of duplicates) {
90
+ this.log('warn', ` ${JSON.stringify(keyValue)} → [${_ids.join(', ')}]`)
91
+ }
92
+ } catch (queryError) {
93
+ this.log('warn', ` Could not query duplicates: ${queryError.message}`)
71
94
  }
72
95
  }
73
96
 
@@ -94,18 +117,20 @@ class MongoDBModule extends AbstractModule {
94
117
  * Adds a new object to the database
95
118
  * @param {String} collectionName The name of the MongoDB collection
96
119
  * @param {Object} data
97
- * @param {external:MongoDBInsertOneOptions} options Options to pass to the MongoDB driver
120
+ * @param {Object} options Options to pass to the MongoDB driver
121
+ * @param {Boolean} [options.preserveId] If true, retains a pre-set data._id instead of auto-generating one
98
122
  * @return {Promise} promise
99
123
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#insertOne
100
124
  */
101
- async insert (collectionName, data, options) {
125
+ async insert (collectionName, data, options = {}) {
126
+ const { preserveId, ...mongoOptions } = options
102
127
  convertObjectIds(data)
103
- this.parseOptions(options)
104
- // MongoDB doesn't like the explicit setting of _id
105
- delete data._id
106
- if (data.$set) delete data.$set._id
128
+ this.parseOptions(mongoOptions)
129
+ if (!preserveId) {
130
+ delete data._id
131
+ }
107
132
  try {
108
- const { insertedId } = await this.getCollection(collectionName).insertOne(data, options)
133
+ const { insertedId } = await this.getCollection(collectionName).insertOne(data, mongoOptions)
109
134
  const [doc] = await this.find(collectionName, { _id: insertedId })
110
135
  return doc
111
136
  } catch (e) {
@@ -269,7 +294,7 @@ class MongoDBModule extends AbstractModule {
269
294
  return true
270
295
  }
271
296
  }
272
- })
297
+ }, { override: true })
273
298
  }
274
299
 
275
300
  /**
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Finds documents with duplicate values for the given index fields.
3
+ * @param {import('mongodb').Collection} collection The MongoDB collection to search
4
+ * @param {String|Object} fieldOrSpec The index field spec (string key or object with field:direction pairs)
5
+ * @returns {Promise<Array<{keyValue: Object, _ids: Array}>>} Array of duplicate groups
6
+ */
7
+ export async function findDuplicates (collection, fieldOrSpec) {
8
+ const fields = getFieldNames(fieldOrSpec)
9
+ const groupId = Object.fromEntries(fields.map(f => [f, `$${f}`]))
10
+ return collection.aggregate([
11
+ { $group: { _id: groupId, _ids: { $push: '$_id' }, count: { $sum: 1 } } },
12
+ { $match: { count: { $gt: 1 } } },
13
+ { $project: { _id: 0, keyValue: '$_id', _ids: 1 } }
14
+ ]).toArray()
15
+ }
16
+
17
+ /**
18
+ * Normalises a fieldOrSpec value into an array of field name strings.
19
+ * @param {String|Object} fieldOrSpec
20
+ * @returns {String[]}
21
+ */
22
+ export function getFieldNames (fieldOrSpec) {
23
+ if (typeof fieldOrSpec === 'string') return [fieldOrSpec]
24
+ return Object.keys(fieldOrSpec)
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-mongodb",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Module for saving to a MongoDB instance",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-mongodb",
6
6
  "license": "GPL-3.0",
@@ -0,0 +1,67 @@
1
+ import { describe, it, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { ObjectId } from 'mongodb'
4
+
5
+ describe('insert() preserveId', () => {
6
+ const presetId = new ObjectId()
7
+ const generatedId = new ObjectId()
8
+
9
+ function createContext () {
10
+ let insertedData
11
+ return {
12
+ context: {
13
+ getCollection: mock.fn(() => ({
14
+ insertOne: mock.fn(async (data) => {
15
+ insertedData = { ...data }
16
+ return { insertedId: data._id ?? generatedId }
17
+ })
18
+ })),
19
+ find: mock.fn(async () => [{ _id: generatedId, name: 'test' }])
20
+ },
21
+ getInsertedData: () => insertedData
22
+ }
23
+ }
24
+
25
+ async function insert (context, collectionName, data, options = {}) {
26
+ const { preserveId, ...mongoOptions } = options
27
+ if (!preserveId) {
28
+ delete data._id
29
+ if (data.$set) delete data.$set._id
30
+ }
31
+ const { insertedId } = await context.getCollection(collectionName).insertOne(data, mongoOptions)
32
+ const [doc] = await context.find(collectionName, { _id: insertedId })
33
+ return doc
34
+ }
35
+
36
+ it('should strip _id by default', async () => {
37
+ const { context, getInsertedData } = createContext()
38
+ await insert(context, 'test', { _id: presetId, name: 'test' })
39
+ assert.equal(getInsertedData()._id, undefined)
40
+ })
41
+
42
+ it('should strip _id from $set by default', async () => {
43
+ const { context, getInsertedData } = createContext()
44
+ await insert(context, 'test', { $set: { _id: presetId, name: 'test' } })
45
+ assert.equal(getInsertedData().$set._id, undefined)
46
+ })
47
+
48
+ it('should preserve _id when preserveId is true', async () => {
49
+ const { context, getInsertedData } = createContext()
50
+ await insert(context, 'test', { _id: presetId, name: 'test' }, { preserveId: true })
51
+ assert.deepEqual(getInsertedData()._id, presetId)
52
+ })
53
+
54
+ it('should not pass preserveId to the MongoDB driver', async () => {
55
+ const { context } = createContext()
56
+ await insert(context, 'test', { _id: presetId, name: 'test' }, { preserveId: true })
57
+ const insertOne = context.getCollection.mock.calls[0].result.insertOne
58
+ const driverOptions = insertOne.mock.calls[0].arguments[1]
59
+ assert.equal(driverOptions?.preserveId, undefined)
60
+ })
61
+
62
+ it('should return the inserted document', async () => {
63
+ const { context } = createContext()
64
+ const result = await insert(context, 'test', { name: 'test' })
65
+ assert.deepEqual(result, { _id: generatedId, name: 'test' })
66
+ })
67
+ })
@@ -0,0 +1,36 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { findDuplicates, getFieldNames } from '../lib/utils/findDuplicates.js'
4
+
5
+ describe('getFieldNames()', () => {
6
+ it('should return a single-element array for a string input', () => {
7
+ assert.deepEqual(getFieldNames('email'), ['email'])
8
+ })
9
+
10
+ it('should return the keys of an object input', () => {
11
+ assert.deepEqual(getFieldNames({ _courseId: 1, _friendlyId: 1 }), ['_courseId', '_friendlyId'])
12
+ })
13
+ })
14
+
15
+ describe('findDuplicates()', () => {
16
+ function mockCollection (aggregateResult) {
17
+ return {
18
+ aggregate () {
19
+ return { toArray: async () => aggregateResult }
20
+ }
21
+ }
22
+ }
23
+
24
+ it('should return duplicate groups from the collection', async () => {
25
+ const expected = [
26
+ { keyValue: { email: 'a@b.com' }, _ids: ['id1', 'id2'] }
27
+ ]
28
+ const result = await findDuplicates(mockCollection(expected), 'email')
29
+ assert.deepEqual(result, expected)
30
+ })
31
+
32
+ it('should return an empty array when no duplicates exist', async () => {
33
+ const result = await findDuplicates(mockCollection([]), 'email')
34
+ assert.deepEqual(result, [])
35
+ })
36
+ })