adapt-authoring-mongodb 3.0.1 → 3.2.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,32 +1,17 @@
1
1
  name: Release
2
+
2
3
  on:
3
4
  push:
4
5
  branches:
5
6
  - master
6
7
 
8
+ permissions:
9
+ contents: write
10
+ issues: write
11
+ pull-requests: write
12
+ id-token: write
13
+ packages: write
14
+
7
15
  jobs:
8
16
  release:
9
- name: Release
10
- runs-on: ubuntu-latest
11
- permissions:
12
- contents: write # to be able to publish a GitHub release
13
- issues: write # to be able to comment on released issues
14
- pull-requests: write # to be able to comment on released pull requests
15
- id-token: write # to enable use of OIDC for trusted publishing and npm provenance
16
- steps:
17
- - name: Checkout
18
- uses: actions/checkout@v3
19
- with:
20
- fetch-depth: 0
21
- - name: Setup Node.js
22
- uses: actions/setup-node@v3
23
- with:
24
- node-version: 'lts/*'
25
- - name: Update npm
26
- run: npm install -g npm@latest
27
- - name: Install dependencies
28
- run: npm install
29
- - name: Release
30
- env:
31
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32
- run: npx semantic-release
17
+ uses: adaptlearning/semantic-release-config/.github/workflows/release.yml@master
@@ -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
  /**
@@ -11,7 +12,6 @@ import { parseObjectId } from './utils/parseObjectId.js'
11
12
  class MongoDBModule extends AbstractModule {
12
13
  /** @override */
13
14
  async init () {
14
- await this.app.waitForModule('config')
15
15
  /**
16
16
  * Reference to the MongDB client
17
17
  * @type {external:MongoDBMongoClient}
@@ -67,7 +67,29 @@ class MongoDBModule extends AbstractModule {
67
67
  try {
68
68
  await this.getCollection(collectionName).createIndex(fieldOrSpec, options)
69
69
  } catch (e) {
70
- this.log('warn', e.toString())
70
+ if (e.code === 11000) {
71
+ await this.logDuplicateIndexError(collectionName, fieldOrSpec, e)
72
+ } else {
73
+ this.log('warn', e.toString())
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Logs detailed information about duplicate index errors
80
+ * @param {String} collectionName The name of the MongoDB collection
81
+ * @param {String|Array|Object} fieldOrSpec Definition of the index
82
+ * @param {Error} error The original MongoDB error
83
+ */
84
+ async logDuplicateIndexError (collectionName, fieldOrSpec, error) {
85
+ this.log('warn', `Duplicate index error on '${collectionName}': ${error.message}`)
86
+ try {
87
+ const duplicates = await findDuplicates(this.getCollection(collectionName), fieldOrSpec)
88
+ for (const { keyValue, _ids } of duplicates) {
89
+ this.log('warn', ` ${JSON.stringify(keyValue)} → [${_ids.join(', ')}]`)
90
+ }
91
+ } catch (queryError) {
92
+ this.log('warn', ` Could not query duplicates: ${queryError.message}`)
71
93
  }
72
94
  }
73
95
 
@@ -94,18 +116,20 @@ class MongoDBModule extends AbstractModule {
94
116
  * Adds a new object to the database
95
117
  * @param {String} collectionName The name of the MongoDB collection
96
118
  * @param {Object} data
97
- * @param {external:MongoDBInsertOneOptions} options Options to pass to the MongoDB driver
119
+ * @param {Object} options Options to pass to the MongoDB driver
120
+ * @param {Boolean} [options.preserveId] If true, retains a pre-set data._id instead of auto-generating one
98
121
  * @return {Promise} promise
99
122
  * @see https://mongodb.github.io/node-mongodb-native/4.2/classes/Collection.html#insertOne
100
123
  */
101
- async insert (collectionName, data, options) {
124
+ async insert (collectionName, data, options = {}) {
125
+ const { preserveId, ...mongoOptions } = options
102
126
  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
127
+ this.parseOptions(mongoOptions)
128
+ if (!preserveId) {
129
+ delete data._id
130
+ }
107
131
  try {
108
- const { insertedId } = await this.getCollection(collectionName).insertOne(data, options)
132
+ const { insertedId } = await this.getCollection(collectionName).insertOne(data, mongoOptions)
109
133
  const [doc] = await this.find(collectionName, { _id: insertedId })
110
134
  return doc
111
135
  } catch (e) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Finds documents with duplicate values for the given index fields.
3
+ * @param {external:MongoDBCollection} 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.1",
3
+ "version": "3.2.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",
@@ -8,46 +8,19 @@
8
8
  "main": "index.js",
9
9
  "repository": "github:adapt-security/adapt-authoring-mongodb",
10
10
  "dependencies": {
11
- "adapt-authoring-core": "^2.0.0",
11
+ "adapt-authoring-core": "^3.0.0",
12
12
  "mongodb": "^7.0.0"
13
13
  },
14
14
  "peerDependencies": {
15
- "adapt-authoring-config": "^1.3.0",
16
- "adapt-authoring-core": "^2.0.0",
15
+ "adapt-authoring-core": "^3.0.0",
17
16
  "adapt-authoring-jsonschema": "^1.2.2"
18
17
  },
19
18
  "devDependencies": {
20
- "@semantic-release/git": "^10.0.1",
21
- "conventional-changelog-eslint": "^6.0.0",
22
- "semantic-release": "^25.0.2",
19
+ "@adaptlearning/semantic-release-config": "^1.0.0",
23
20
  "standard": "^17.1.0"
24
21
  },
25
22
  "release": {
26
- "plugins": [
27
- [
28
- "@semantic-release/commit-analyzer",
29
- {
30
- "preset": "eslint"
31
- }
32
- ],
33
- [
34
- "@semantic-release/release-notes-generator",
35
- {
36
- "preset": "eslint"
37
- }
38
- ],
39
- "@semantic-release/npm",
40
- "@semantic-release/github",
41
- [
42
- "@semantic-release/git",
43
- {
44
- "assets": [
45
- "package.json"
46
- ],
47
- "message": "Chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
48
- }
49
- ]
50
- ]
23
+ "extends": "@adaptlearning/semantic-release-config"
51
24
  },
52
25
  "scripts": {
53
26
  "test": "node --test 'tests/**/*.spec.js'"
@@ -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
+ })