• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ubccpsc / classy / 47029edf-f331-4b4d-bbff-ed4f07561520

19 Dec 2024 09:55PM UTC coverage: 87.102% (-0.2%) from 87.336%
47029edf-f331-4b4d-bbff-ed4f07561520

push

circleci

web-flow
Merge pull request #466 from ubccpsc310/cs310_24w1

Incorporate 310_24w1 improvements.

1108 of 1350 branches covered (82.07%)

Branch coverage included in aggregate %.

3950 of 4457 relevant lines covered (88.62%)

35.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.71
packages/portal/backend/src/controllers/DatabaseController.ts
1
import {Collection, Db, MongoClient} from "mongodb";
1✔
2

3
import Config, {ConfigCourses, ConfigKey} from "@common/Config";
1✔
4
import Log from "@common/Log";
1✔
5
import Util from "@common/Util";
1✔
6

7
import {AuditEvent, AuditLabel, Auth, Course, Deliverable, FeedbackRecord, Grade, Person, Repository, Result, Team} from "../Types";
8
import {TeamController} from "./TeamController";
1✔
9

10
export class DatabaseController {
1✔
11
    /**
12
     * Returns the current controller; shares Mongo connections.
13
     *
14
     * @returns {DatabaseController}
15
     */
16
    public static getInstance() {
17
        if (DatabaseController.instance === null) {
2,441✔
18
            DatabaseController.instance = new DatabaseController();
1✔
19

20
            // verify that any mandatory objects that are in the db
21
            // not great to do this on
22
        }
23
        return DatabaseController.instance;
2,441✔
24
    }
25

26
    private static instance: DatabaseController = null;
1✔
27

28
    private db: Db = null;
1✔
29
    private writeDb: Db = null;
1✔
30
    private slowDb: Db = null;
1✔
31

32
    private readonly COURSECOLL = "course";
1✔
33
    private readonly PERSONCOLL = "people";
1✔
34
    private readonly GRADECOLL = "grades";
1✔
35
    private readonly RESULTCOLL = "results";
1✔
36
    private readonly TEAMCOLL = "teams";
1✔
37
    private readonly DELIVCOLL = "deliverables";
1✔
38
    private readonly REPOCOLL = "repositories";
1✔
39
    private readonly AUTHCOLL = "auth";
1✔
40
    private readonly AUDITCOLL = "audit";
1✔
41
    private readonly TICKERCOLL = "ids";
1✔
42

43
    private readonly FEEDBACKCOLL = "feedback";
1✔
44

45
    /**
46
     * use getInstance() instead.
47
     */
48
    private constructor() {
49
        Log.info("DatabaseController::<init> - creating new controller");
1✔
50
    }
51

52
    public async getPerson(recordId: string): Promise<Person | null> {
53
        const person = await this.readSingleRecord(this.PERSONCOLL, {id: recordId}) as Person;
945✔
54
        Log.trace("DatabaseController::getPerson( " + recordId + " ) - found: " + (person !== null));
945✔
55
        return person;
945✔
56
    }
57

58
    public async getGitHubPerson(recordId: string): Promise<Person | null> {
59
        const person = await this.readSingleRecord(this.PERSONCOLL, {githubId: recordId}) as Person;
81✔
60
        Log.trace("DatabaseController::getGitHubPerson( " + recordId + " ) - found: " + (person !== null));
81✔
61
        return person;
81✔
62
    }
63

64
    public async getRepository(recordId: string): Promise<Repository | null> {
65
        const repo = await this.readSingleRecord(this.REPOCOLL, {id: recordId}) as Repository;
395✔
66
        Log.trace("DatabaseController::getRepository( " + recordId + " ) - found: " + (repo !== null));
395✔
67
        return repo;
395✔
68
    }
69

70
    public async getTeam(recordId: string): Promise<Team | null> {
71
        const team = await this.readSingleRecord(this.TEAMCOLL, {id: recordId}) as Team;
542✔
72
        Log.trace("DatabaseController::getTeam( " + recordId + " ) - found: " + (team !== null));
542✔
73
        return team;
542✔
74
    }
75

76
    public async getAuth(personId: string): Promise<Auth | null> {
77
        const auth = await this.readSingleRecord(this.AUTHCOLL, {personId: personId}) as Auth;
150✔
78
        Log.trace("DatabaseController::getAuthToken( " + personId + " ) - found: " + (auth !== null));
150✔
79
        return auth;
150✔
80
    }
81

82
    public async getRepositories(): Promise<Repository[]> {
83
        const repos = await this.readRecords(this.REPOCOLL, QueryKind.FAST, false, {}) as Repository[];
32✔
84
        Log.trace("DatabaseController::getRepositories() - #: " + repos.length);
32✔
85
        return repos;
32✔
86
    }
87

88
    public async getCourseRecord(): Promise<Course | null> {
89
        const record = await this.readSingleRecord(this.COURSECOLL, {id: Config.getInstance().getProp(ConfigKey.name)}) as Course;
30✔
90
        Log.trace("DatabaseController::getCourseRecord() - found: " + (record !== null));
30✔
91
        return record;
30✔
92
    }
93

94
    public async getTeams(): Promise<Team[]> {
95
        const teams = await this.readRecords(this.TEAMCOLL, QueryKind.FAST, false, {}) as Team[];
76✔
96
        Log.trace("DatabaseController::getTeams() - #: " + teams.length);
76✔
97
        return teams;
76✔
98
    }
99

100
    public async getAllResults(): Promise<Result[]> {
101
        const query = {};
11✔
102
        const start = Date.now();
11✔
103
        Log.trace("DatabaseController::getAllResults() - start");
11✔
104
        // const latestFirst = {"input.pushInfo.timestamp": -1}; // most recent first
105
        const latestFirst = {"input.target.timestamp": -1}; // most recent first
11✔
106
        const results = await this.readRecords(this.RESULTCOLL, QueryKind.SLOW, false, query, latestFirst) as Result[];
11✔
107

108
        for (const result of results) {
11✔
109
            if (typeof (result.input as any).pushInfo !== "undefined" && typeof result.input.target === "undefined") {
97!
110
                // this is a backwards compatibility step that can disappear in 2019 (except for sdmm which will need further changes)
111
                result.input.target = (result.input as any).pushInfo;
×
112
            }
113
        }
114
        Log.trace("DatabaseController::getAllResults() - done; #: " + results.length + "; took: " + Util.took(start));
11✔
115
        return results;
11✔
116
    }
117

118
    public async getGradedResults(deliv: string): Promise<Result[]> {
119
        const start = Date.now();
1✔
120
        Log.trace("DatabaseController::getGradedResults() - start");
1✔
121
        const pipeline = [
1✔
122
            {$match: {delivId: deliv}},
123
            {$group: {_id: "$URL"}},
124
            {
125
                $lookup:
126
                    {
127
                        from: this.RESULTCOLL,
128
                        localField: "_id",
129
                        foreignField: "commitURL",
130
                        as: "results"
131
                    }
132
            },
133
            {$project: {result: {$arrayElemAt: ["$results", 0]}}}
134
        ];
135
        const collection = await this.getCollection(this.GRADECOLL, QueryKind.SLOW);
1✔
136
        const results = (await collection.aggregate(pipeline).toArray()).map((r) => r.result);
1✔
137
        results.forEach((r) => delete r._id);
1✔
138
        Log.trace("DatabaseController::getGradedResults() - done; #: " + results.length + "; took: " + Util.took(start));
1✔
139
        return results;
1✔
140
    }
141

142
    public async getBestResults(deliv: string): Promise<Result[]> {
143
        const start = Date.now();
3✔
144
        Log.trace("DatabaseController::getBestResults() - start");
3✔
145
        const pipeline = [
3✔
146
            {$match: {delivId: deliv}},
147
            {$group: {_id: "$repoId", maxScore: {$max: "$output.report.scoreOverall"}}},
148
            {
149
                $lookup:
150
                    {
151
                        from: this.RESULTCOLL,
152
                        let: {srcRepo: "$_id", score: "$maxScore"},
153
                        pipeline: [
154
                            {
155
                                $match:
156
                                    {
157
                                        $expr:
158
                                            {
159
                                                $and:
160
                                                    [
161
                                                        {$eq: ["$repoId", "$$srcRepo"]},
162
                                                        {$eq: ["$output.report.scoreOverall", "$$score"]}
163
                                                    ]
164
                                            }
165
                                    }
166
                            }
167
                        ],
168
                        as: "results"
169
                    }
170
            },
171
            {$project: {result: {$arrayElemAt: ["$results", 0]}}}
172
        ];
173
        const collection = await this.getCollection(this.RESULTCOLL, QueryKind.SLOW);
3✔
174
        const results = (await collection.aggregate(pipeline).toArray()).map((r) => r.result);
3✔
175
        results.forEach((r) => delete r._id);
3✔
176
        Log.trace("DatabaseController::getBestResults() - done; #: " + results.length + "; took: " + Util.took(start));
3✔
177
        return results;
3✔
178
    }
179

180
    public async getTeamsForPerson(personId: string): Promise<Team[]> {
181
        Log.trace("DatabaseController::getTeamsForPerson( " + personId + " ) - start");
2✔
182
        const teams = await this.readRecords(this.TEAMCOLL, QueryKind.FAST, false, {});
2✔
183
        const myTeams = [];
2✔
184
        for (const t of teams as Team[]) {
2✔
185
            if (t.personIds.indexOf(personId) >= 0) {
9!
186
                myTeams.push(t);
×
187
            }
188
        }
189
        return myTeams;
2✔
190
    }
191

192
    public async getRepositoriesForPerson(personId: string): Promise<Repository[]> {
193
        Log.trace("DatabaseController::getRepositoriesForPerson() - start");
3✔
194

195
        const query = [
3✔
196
            {
197
                $lookup: {
198
                    from: "teams",
199
                    localField: "teamIds",
200
                    foreignField: "id",
201
                    as: "teams"
202
                }
203
            },
204
            {
205
                $lookup: {
206
                    from: "people",
207
                    localField: "teams.personIds",
208
                    foreignField: "id",
209
                    as: "teammembers"
210
                }
211
            },
212
            {
213
                $match: {"teammembers.id": personId}
214
            }
215
        ];
216

217
        const collection = await this.getCollection(this.REPOCOLL, QueryKind.FAST);
3✔
218
        return await collection.aggregate(query).toArray() as any[];
3✔
219
    }
220

221
    public async getPeople(): Promise<Person[]> {
222
        const people = await this.readRecords(this.PERSONCOLL, QueryKind.FAST, false, {}) as Person[];
58✔
223
        Log.trace("DatabaseController::getPeople() - #: " + people.length);
58✔
224
        return people;
58✔
225
    }
226

227
    public async getDeliverables(): Promise<Deliverable[]> {
228
        const delivs = await this.readRecords(this.DELIVCOLL, QueryKind.FAST, false, {}) as Deliverable[];
20✔
229
        Log.trace("DatabaseController::getDeliverables() - #: " + delivs.length);
20✔
230
        return delivs;
20✔
231
    }
232

233
    public async getDeliverable(id: string): Promise<Deliverable> {
234
        const deliv = await this.readSingleRecord(this.DELIVCOLL, {id}) as Deliverable;
251✔
235
        Log.trace("DatabaseController::getDeliverable() - found: " + (deliv !== null));
251✔
236
        return deliv;
251✔
237
    }
238

239
    /**
240
     * Returns all grades in the system. To increase performance, each grade record is pruned
241
     * and its `custom` parameter has been removed.
242
     *
243
     * If you need the `custom` parameter, use `getGrade` instead.
244
     *
245
     * @returns {Promise<Grade[]>} all grades, less their custom field
246
     */
247
    public async getGrades(): Promise<Grade[]> {
248
        const start = Date.now();
10✔
249
        Log.trace("DatabaseController::getGrades() - start");
10✔
250
        const grades = await this.readRecords(this.GRADECOLL, QueryKind.SLOW, false, {}) as Grade[];
10✔
251
        grades.forEach((g) => delete g?.custom?.previousGrade); // remove the custom field
14!
252

253
        // this query works, but is not any faster than the simple one above
254
        // although it does remove the custom field, which is recursive and can be large
255
        // const col = await this.getCollection(this.GRADECOLL, QueryKind.FAST);
256
        // const grades = await col.aggregate([
257
        //     {$project: {_id: 0, custom: 0}}, // exclude _id and custom (custom.previousGrade is large)
258
        //     {
259
        //         $group: {
260
        //             _id: {delivId: "$delivId", personId: "$personId"},
261
        //             doc: {$first: "$$ROOT"}
262
        //         }
263
        //     },
264
        //     {$replaceRoot: {newRoot: "$doc"}}
265
        // ]).toArray() as Grade[];
266

267
        Log.trace("DatabaseController::getGrades() - done; #: " + grades.length + "; took: " + Util.took(start));
10✔
268
        return grades;
10✔
269
    }
270

271
    public async getGrade(personId: string, delivId: string): Promise<Grade | null> {
272
        const grade = await this.readSingleRecord(this.GRADECOLL, {personId: personId, delivId: delivId}) as Grade;
122✔
273
        if (grade !== null) {
122✔
274
            Log.trace("DatabaseController::getGrade( " + personId + ", " + delivId + " ) - grade: " + grade.score);
63✔
275
        } else {
276
            Log.trace("DatabaseController::getGrade( " + personId + ", " + delivId + " ) - not found");
59✔
277
        }
278
        return grade;
122✔
279
    }
280

281
    /**
282
     *
283
     * @param delivId
284
     * @param personId
285
     * @param kind - "standard" or "check"
286
     * @returns list of FeedbackRecords that have been shown to the personId
287
     */
288
    public async getLatestFeedbackGiven(delivId: string, personId: string, kind: string): Promise<FeedbackRecord[] | null> {
289
        try {
1✔
290
            const latestFirst = {timestamp: -1};
1✔
291
            const res = await this.readRecords(
1✔
292
                this.FEEDBACKCOLL,
293
                QueryKind.FAST,
294
                true, // limit results to 400. We probably don't need that many, but there's no limit parameter for readRecords (yet)
295
                {delivId, personId, kind},
296
                latestFirst
297
            ) as FeedbackRecord[];
298
            Log.trace("DatabaseController::getLatestFeedbackGiven() - #: " + res.length);
1✔
299
            return res;
1✔
300
        } catch (err) {
301
            Log.error("DatabaseController::getLatestFeedbackGiven(..) - ERROR: " + err);
×
302
        }
303
        return null;
×
304
    }
305

306
    /**
307
     * Gets and increments a per-deliverable counter. Numbers start at 000 and increment.
308
     *
309
     * Pre-padding with 0s for easier searching, but will be problematic for deliverables with > 1000 teams.
310
     *
311
     * @param {string} delivId
312
     * @returns {Promise<string>}
313
     */
314
    public async getUniqueTeamNumber(delivId: string): Promise<string> {
315
        const ticker = await this.readAndUpdateSingleRecord(this.TICKERCOLL, {tickerId: delivId}, {$inc: {ticker: 1}});
×
316
        let res: number = 0;
×
317
        if (ticker !== null) {
×
318
            Log.trace("DatabaseController::getUniqueTeamNumber() - " + delivId + " ticker found: " + ticker.ticker);
×
319
            res = ticker.ticker;
×
320
        } else {
321
            Log.trace("DatabaseController::getUniqueTeamNumber() - " + delivId + " ticker NOT found. Setting ticker");
×
322
            await this.writeRecord(this.TICKERCOLL, {tickerId: delivId, ticker: 1});
×
323
        }
324
        return ("00" + res).slice(-3);
×
325
    }
326

327
    public async writePerson(record: Person): Promise<boolean> {
328
        const existingPerson = await this.getPerson(record.id);
342✔
329
        if (existingPerson === null) {
342✔
330
            return await this.writeRecord(this.PERSONCOLL, record);
246✔
331
        } else {
332
            const query = {id: record.id};
96✔
333
            return await this.updateRecord(this.PERSONCOLL, query, record);
96✔
334
        }
335
    }
336

337
    public async writeTeam(record: Team): Promise<boolean> {
338
        const existingTeam = await this.getTeam(record.id);
185✔
339
        if (existingTeam === null) {
185✔
340
            return await this.writeRecord(this.TEAMCOLL, record);
117✔
341
        } else {
342
            const query = {id: record.id};
68✔
343
            return await this.updateRecord(this.TEAMCOLL, query, record);
68✔
344
        }
345
    }
346

347
    public async writeCourseRecord(record: Course): Promise<boolean> {
348
        const existingRecord = await this.getCourseRecord();
12✔
349
        if (existingRecord === null) {
12✔
350
            return await this.writeRecord(this.COURSECOLL, record);
5✔
351
        } else {
352
            const query = {id: record.id};
7✔
353
            return await this.updateRecord(this.COURSECOLL, query, record);
7✔
354
        }
355
    }
356

357
    /**
358
     * Result records are associated with repos, SHAs, and delivIds. Really the repo could be removed
359
     * as the SHA (or commitURL) would be enough to work with the delivId.
360
     *
361
     * @param {Result} record
362
     * @returns {Promise<boolean>}
363
     */
364
    public async writeResult(record: Result): Promise<boolean> {
365
        Log.trace("DatabaseController::writeResult(..) - start");
174✔
366

367
        const resultExists = await this.getResult(record.delivId, record.repoId, record.commitSHA, record.input.target.ref);
174✔
368
        if (resultExists === null) {
174✔
369
            Log.trace("DatabaseController::writeResult(..) - new");
173✔
370
            return await this.writeRecord(this.RESULTCOLL, record);
173✔
371
        } else {
372
            Log.trace("DatabaseController::writeResult(..) - update");
1✔
373
            const query = {commitSHA: record.commitSHA, repoId: record.repoId, delivId: record.delivId};
1✔
374
            return await this.updateRecord(this.RESULTCOLL, query, record);
1✔
375
        }
376
    }
377

378
    public async deleteAuth(record: Auth): Promise<boolean> {
379
        if (record !== null) {
6✔
380
            const success = await this.deleteRecord(this.AUTHCOLL, {personId: record.personId});
4✔
381
            Log.info("DatabaseController::deleteAuth( " + record.personId + " ) - success: " + success);
4✔
382
            return success;
4✔
383
        }
384
        return false;
2✔
385
    }
386

387
    public async deleteRepository(record: Repository): Promise<boolean> {
388
        if (record !== null) {
4✔
389
            Log.info("DatabaseController::deleteRepository( " + record.id + " ) - start");
3✔
390
            return await this.deleteRecord(this.REPOCOLL, {id: record.id});
3✔
391
        }
392
        return false;
1✔
393
    }
394

395
    public async deleteDeliverable(record: Deliverable): Promise<boolean> {
396
        if (record !== null) {
1!
397
            Log.info("DatabaseController::deleteDeliverable( " + record.id + " ) - start");
1✔
398
            return await this.deleteRecord(this.DELIVCOLL, {id: record.id});
1✔
399
        }
400
        return false;
×
401
    }
402

403
    public async deletePerson(record: Person): Promise<boolean> {
404
        if (record !== null) {
2✔
405
            Log.info("DatabaseController::deletePerson( " + record.id + " ) - start");
1✔
406
            return await this.deleteRecord(this.PERSONCOLL, {id: record.id});
1✔
407
        }
408
        return false;
1✔
409
    }
410

411
    public async deleteTeam(record: Team): Promise<boolean> {
412
        if (record !== null) {
4✔
413
            Log.info("DatabaseController::deleteTeam( " + record.id + " ) - start");
3✔
414
            return await this.deleteRecord(this.TEAMCOLL, {id: record.id});
3✔
415
        }
416
        return false;
1✔
417
    }
418

419
    private async deleteRecord(colName: string, query: {}): Promise<boolean> {
420
        Log.trace("DatabaseController::deleteRecord( " + colName + ", " + JSON.stringify(query) + " ) - start");
12✔
421
        try {
12✔
422
            const collection = await this.getCollection(colName);
12✔
423
            const res = await collection.deleteOne(query);
12✔
424
            Log.trace("DatabaseController::deleteRecord(..) - delete complete; result: " + JSON.stringify(res));
12✔
425
            return true;
12✔
426
        } catch (err) {
427
            Log.error("DatabaseController::deleteRecord(..) - ERROR: " + err);
×
428
            return false;
×
429
        }
430
    }
431

432
    public async writeDeliverable(record: Deliverable): Promise<boolean> {
433
        const existingDeiverable = await this.getDeliverable(record.id);
108✔
434
        if (existingDeiverable === null) {
108✔
435
            return await this.writeRecord(this.DELIVCOLL, record);
89✔
436
        } else {
437
            const query = {id: record.id};
19✔
438
            return await this.updateRecord(this.DELIVCOLL, query, record);
19✔
439
        }
440
    }
441

442
    /**
443
     * Writes a grade record. If a grade record (<delivId, personId> tuple) already exists, it is updated.
444
     * This enforces that there will be at most one grade record per <delivId, personId> tuple.
445
     *
446
     * @param {Grade} record
447
     * @returns {Promise<boolean>} whether the write was successful
448
     */
449
    public async writeGrade(record: Grade): Promise<boolean> {
450
        const p = await this.getPerson(record.personId);
44✔
451
        if (p === null) {
44!
452
            Log.warn("DatabaseController::writeGrade(..) - trying to write a grade for a personId that does not exist");
×
453
        }
454

455
        const gradeExists = await this.getGrade(record.personId, record.delivId);
44✔
456
        if (gradeExists === null) {
44✔
457
            return await this.writeRecord(this.GRADECOLL, record);
25✔
458
        } else {
459
            const query = {personId: record.personId, delivId: record.delivId};
19✔
460
            return await this.updateRecord(this.GRADECOLL, query, record);
19✔
461
        }
462
    }
463

464
    public async writeAudit(label: AuditLabel, personId: string, before: any, after: any, custom: any): Promise<boolean> {
465

466
        const isEmpty = function (obj: any): boolean {
44✔
467
            if (typeof obj === "undefined" || obj === null) {
192✔
468
                return true;
24✔
469
            }
470
            return Object.keys(obj).length === 0 && obj.constructor === Object;
168✔
471
        };
472

473
        try {
44✔
474
            Log.trace("DatabaseController::writeAudit( " + label + ", " + personId + ", hasBefore: " +
44✔
475
                !isEmpty(before) + ", hasAfter: " + !isEmpty(after) + " ) - start");
476

477
            let finalLabel = label + "_";
44✔
478
            if (isEmpty(before) === true && isEmpty(after) === true) {
44✔
479
                // is an action, no postfix
480
                finalLabel = label;
28✔
481
            } else if (isEmpty(before) === true) {
16✔
482
                finalLabel = finalLabel + "CREATE";
7✔
483
            } else if (isEmpty(after) === true) {
9✔
484
                finalLabel = finalLabel + "DELETE";
3✔
485
            } else {
486
                finalLabel = finalLabel + "UPDATE";
6✔
487
            }
488
            const auditRecord: AuditEvent = {
44✔
489
                label: finalLabel,
490
                timestamp: Date.now(),
491
                personId: personId,
492
                before: before,
493
                after: after,
494
                custom: custom
495
            };
496
            return await this.writeRecord(this.AUDITCOLL, auditRecord);
44✔
497
        } catch (err) {
498
            // never want this to mess with whatever called it; eat all errors
499
            Log.error("DatabaseController::writeAudit(..) - ERROR: " + err.message);
×
500
            return false;
×
501
        }
502
    }
503

504
    public async writeRepository(record: Repository): Promise<boolean> {
505
        if (record.custom === null) {
107!
506
            record.custom = {}; // make sure this always exists
×
507
        }
508
        const existingRepo = await this.getRepository(record.id);
107✔
509
        if (existingRepo === null) {
107✔
510
            return await this.writeRecord(this.REPOCOLL, record);
38✔
511
        } else {
512
            const query = {id: record.id};
69✔
513
            return await this.updateRecord(this.REPOCOLL, query, record);
69✔
514
        }
515
    }
516

517
    public async writeRecord(colName: string, record: {}): Promise<boolean> {
518
        Log.trace("DatabaseController::writeRecord( " + colName + ", ...) - writing");
795✔
519
        // Log.trace("DatabaseController::writeRecord(..) - col: " + colName + "; record: " + JSON.stringify(record));
520
        try {
795✔
521
            const collection = await this.getCollection(colName, QueryKind.WRITE);
795✔
522
            const copy = Object.assign({}, record);
795✔
523
            await collection.insertOne(copy);
795✔
524
            // Log.trace("DatabaseController::writeRecord(..) - write complete");
525
            return true;
795✔
526
        } catch (err) {
527
            Log.error("DatabaseController::writeRecord(..) - ERROR: " + err);
×
528
            return false;
×
529
        }
530
    }
531

532
    public async updateRecord(colName: string, query: {}, record: {}): Promise<boolean> {
533
        Log.trace("DatabaseController::updateRecord( " + colName + ", ...) - start");
287✔
534
        Log.trace("DatabaseController::updateRecord(..) - colName: " + colName + "; record: " + JSON.stringify(record));
287✔
535
        try {
287✔
536
            const collection = await this.getCollection(colName, QueryKind.WRITE);
287✔
537
            const copy = Object.assign({}, record);
287✔
538
            const res = await collection.replaceOne(query, copy); // copy was record
287✔
539
            Log.trace("DatabaseController::updateRecord(..) - write complete; res: " + JSON.stringify(res));
287✔
540
            return true;
287✔
541
        } catch (err) {
542
            Log.error("DatabaseController::updateRecord(..) - ERROR: " + err);
×
543
            return false;
×
544
        }
545
    }
546

547
    /**
548
     * Returns a ready-to-use `collection` object from MongoDB.
549
     *
550
     * Usage:
551
     *
552
     *   (await getCollection("users")).find().toArray().then( ... )
553
     */
554
    public async getCollection(collectionName: string, queryKind?: QueryKind): Promise<Collection> {
555
        try {
4,347✔
556
            let db;
557
            if (typeof queryKind === "undefined" || queryKind === null) {
4,347✔
558
                db = await this.open(QueryKind.FAST);
278✔
559
            } else if (queryKind === QueryKind.SLOW) {
4,069✔
560
                db = await this.open(QueryKind.SLOW);
209✔
561
            } else {
562
                db = await this.open(QueryKind.FAST);
3,860✔
563
            }
564

565
            return db.collection(collectionName);
4,347✔
566
        } catch (err) {
567
            Log.error("DatabaseController::getCollection( " + collectionName +
×
568
                " ) - Mongo is probably not running; ERROR: " + err.message);
569
            process.exit(-1); // this is a fatal failure
×
570
        }
571
    }
572

573
    public async clearData(): Promise<void> {
574
        Log.warn("DatabaseController::clearData() - start (WARNING: ONLY USE THIS FOR DEBUGGING!)");
22✔
575

576
        const configName = Config.getInstance().getProp(ConfigKey.name);
22✔
577
        Log.warn("DatabaseController::clearData() - name: " + configName);
22✔
578

579
        if (configName === ConfigCourses.classytest) {
22!
580
            // NOTE: can only delete data if the current instance is the main test instance
581
            // This prevents us from running the tests in production by accident and wiping the database
582

583
            const cols = [this.PERSONCOLL, this.GRADECOLL, this.RESULTCOLL, this.TEAMCOLL,
22✔
584
                this.DELIVCOLL, this.REPOCOLL, this.AUTHCOLL, this.COURSECOLL, this.AUDITCOLL, this.TICKERCOLL];
585

586
            for (const col of cols) {
22✔
587
                Log.info("DatabaseController::clearData() - removing data for collection: " + col);
220✔
588
                const collection = await this.getCollection(col);
220✔
589
                await collection.deleteMany({});
220✔
590
            }
591
            Log.info("DatabaseController::clearData() - data removed");
22✔
592

593
            await this.initDatabase();
22✔
594
            Log.info("DatabaseController::clearData() - database reset with initial objects");
22✔
595
        } else {
596
            throw new Error("DatabaseController::clearData() - can only be called on test configurations");
×
597
        }
598
        return;
22✔
599
    }
600

601
    /**
602
     * Perform a query and return a single record. If the query matches more than one record, the first is
603
     * returned; if no records are found, null is returned.
604
     *
605
     * @param {string} column
606
     * @param {{}} query
607
     * @returns {Promise<{} | null>}
608
     */
609
    private async readSingleRecord(column: string, query: {}): Promise<{} | null> {
610
        try {
2,582✔
611
            // Log.trace("DatabaseController::readSingleRecord( " + column + ", " + JSON.stringify(query) + " ) - start");
612
            const col = await this.getCollection(column, QueryKind.FAST);
2,582✔
613

614
            const records: any[] = await (col as any).find(query).toArray();
2,582✔
615
            if (records === null || records.length === 0) {
2,582✔
616
                // Log.trace("DatabaseController::readSingleRecord(..) - done; no records found; took: " + Util.took(start));
617
                return null;
951✔
618
            } else {
619
                // Log.trace("DatabaseController::readSingleRecord(..) - done; # records: " +
620
                // records.length + "; took: " + Util.took(start));
621
                const record = records[0];
1,631✔
622
                delete record._id; // remove the record id, just so we cannot use it
1,631✔
623
                return record;
1,631✔
624
            }
625
        } catch (err) {
626
            Log.error("DatabaseController::readSingleRecord(..) - ERROR: " + err);
×
627
            return null;
×
628
        }
629
    }
630

631
    /**
632
     * Perform a query on a collection. Support is provided for limiting the number of results returned,
633
     * as all results are often not needed, but in busy courses the number of records can exceed 100,000
634
     * which can take way too long to return.
635
     *
636
     * @param {string} column
637
     * @param {QueryKind} kind this is the kind of query ("slow", "write", or null)
638
     * @param {boolean} limitResults whether the full result list should be returned or just a subset
639
     * @param {{}} query send {} if all results for that column are wanted
640
     * @param {{}} sort send only if a specific ordering is required
641
     * @returns {Promise<any[]>} An array of objects
642
     */
643
    public async readRecords(column: string, kind: QueryKind, limitResults: boolean, query: {}, sort?: {}): Promise<any[]> {
644
        try {
396✔
645
            if (typeof sort === "undefined") {
396✔
646
                Log.trace("DatabaseController::readRecords( " + column + ", " + JSON.stringify(query) + " ) - start");
200✔
647
            } else {
648
                Log.trace("DatabaseController::readRecords( " + column + ", " +
196✔
649
                    JSON.stringify(query) + ", " + JSON.stringify(sort) + " ) - start");
650
            }
651

652
            let LIMITS = 999999999;
396✔
653
            if (limitResults === true) {
396✔
654
                LIMITS = 400;
1✔
655
                Log.trace("DatabaseController::readRecords( " + column + ", ... ) - limited results query");
1✔
656
            }
657

658
            const start = Date.now();
396✔
659
            const col = await this.getCollection(column, kind);
396✔
660

661
            // mongo query to find the most recent document for each team
662
            let records: any[];
663
            if (typeof sort === "undefined") {
396✔
664
                records = await (col as any).find(query).limit(LIMITS).toArray();
200✔
665
            } else {
666
                records = await (col as any).find(query).limit(LIMITS).sort(sort).toArray();
196✔
667
            }
668

669
            if (records === null || records.length === 0) {
396✔
670
                Log.trace("DatabaseController::readRecords(..) - done; no records found for: " +
40✔
671
                    JSON.stringify(query) + " in: " + column + "; took: " + Util.took(start));
672
                return [];
40✔
673
            } else {
674
                for (const r of records) {
356✔
675
                    delete r._id; // remove the record id, just so we cannot use it
2,343✔
676
                }
677
                Log.trace("DatabaseController::readRecords(..) - done; query: " + JSON.stringify(query) + "; # records: " +
356✔
678
                    records.length + ". took: " + Util.took(start));
679
                return records;
356✔
680
            }
681
        } catch (err) {
682
            Log.error("DatabaseController::readRecords(..) - ERROR: " + err);
×
683
        }
684
        return [];
×
685
    }
686

687
    private async readAndUpdateSingleRecord(column: string, query: {}, update: {}): Promise<any> {
688
        try {
×
689
            const col = await this.getCollection(column, QueryKind.WRITE);
×
690

691
            const record: any = (await (col as any).findOneAndUpdate(query, update)).value;
×
692

693
            if (record === null || record === undefined) {
×
694
                return null;
×
695
            } else {
696
                delete record._id;
×
697
                return record;
×
698
            }
699
        } catch (err) {
700
            Log.error("DatabaseController::readAndUpdateSingleRecord(..) - ERROR: " + err);
×
701
            return null;
×
702
        }
703
    }
704

705
    /**
706
     * Internal use only, do not use this method; use getCollection instead.
707
     *
708
     * @returns {Promise<Db>}
709
     */
710
    private async open(kind: string): Promise<Db> {
711
        try {
4,347✔
712
            // Log.trace("DatabaseController::open() - start");
713
            let db;
714
            if (kind === QueryKind.SLOW) {
4,347✔
715
                db = this.slowDb;
209✔
716
            } else if (kind === QueryKind.WRITE) {
4,138!
717
                db = this.writeDb;
×
718
            } else {
719
                db = this.db;
4,138✔
720
            }
721

722
            if (db === null) {
4,347✔
723
                // just use Config.name for the db (use a test org name if you want to avoid tests wiping data!!)
724
                let dbName = Config.getInstance().getProp(ConfigKey.name).trim(); // make sure there are no extra spaces in config
2✔
725
                const dbHost = Config.getInstance().getProp(ConfigKey.mongoUrl).trim(); // make sure there are no extra spaces in config
2✔
726

727
                /* istanbul ignore if */
728
                if (dbName === "sdmm") {
2✔
729
                    dbName = "secapstone"; // NOTE: this is just an unfortunate historical artifact
730
                }
731

732
                Log.trace("DatabaseController::open() - db null; making new connection to: _" + dbName + "_");
2✔
733
                const client = await MongoClient.connect(dbHost, {serverSelectionTimeoutMS: 500});
2✔
734
                if (kind === "slow") {
2✔
735
                    Log.trace("DatabaseController::open() - creating slowDb");
1✔
736
                    this.slowDb = await client.db(dbName);
1✔
737
                    db = this.slowDb;
1✔
738
                } else if (kind === QueryKind.WRITE) {
1!
739
                    Log.trace("DatabaseController::open() - creating writeDb");
×
740
                    this.writeDb = await client.db(dbName);
×
741
                    db = this.writeDb;
×
742
                } else {
743
                    Log.trace("DatabaseController::open() - creating standard db");
1✔
744
                    this.db = await client.db(dbName);
1✔
745
                    db = this.db;
1✔
746

747
                    // ensure required records / indexes exist
748
                    await this.initDatabase();
1✔
749
                }
750

751
                Log.trace("DatabaseController::open() - db null; new connection made");
2✔
752
            }
753
            // Log.trace("DatabaseController::open() - returning db");
754
            return db;
4,347✔
755
        } catch (err) {
756
            Log.error("DatabaseController::open() - ERROR: " + err);
×
757
            Log.error("DatabaseController::open() - ERROR: Host probably does not have a database configured " +
×
758
                "and running (see README.md if this is a test instance).");
759
        }
760
    }
761

762
    /**
763
     * Collect any actions that need to happen when a database is first opened.
764
     *
765
     * This can include objects or indexes that must be created.
766
     */
767
    private async initDatabase() {
768
        try {
23✔
769
            if (this.db === null) {
23!
770
                throw new Error("DatabaseController::initDatabase() cannot be called before db is set");
×
771
            }
772

773
            // create indexes if they do not exist (idempotent operation; even if index exists this is ok)
774
            // https://stackoverflow.com/a/35020346
775

776
            // results needs a timestamp index because it gets to be too long to iterate through all records (32MB result limit)
777
            let coll = await this.getCollection(this.RESULTCOLL);
23✔
778
            await coll.createIndex({
23✔
779
                "input.target.timestamp": -1
780
            }, {name: "ts"});
781

782
            // results needs a delivId index because we often query by delivId
783
            await coll.createIndex({
23✔
784
                delivId: 1
785
            }, {name: "delivId"});
786
            // results needs a repoId index because we often query by repoId
787
            await coll.createIndex({
23✔
788
                repoId: 1
789
            }, {name: "repoId"});
790
            await coll.createIndex({
23✔
791
                repoId: 1, delivId: 1
792
            }, {name: "delivAndRepoIds"});
793

794
            // grades needs indexes because we group on <personId, delivId> tuples
795
            coll = await this.getCollection(this.GRADECOLL);
23✔
796
            await coll.createIndex({
23✔
797
                personId: 1
798
            }, {name: "personId"});
799
            await coll.createIndex({
23✔
800
                delivId: 1
801
            }, {name: "delivId"});
802
            await coll.createIndex({
23✔
803
                delivId: 1, repoId: 1
804
            }, {name: "delivAndRepoIds"});
805

806
            // Make sure required Team objects exist.
807
            // Cannot use TeamController because this would cause an infinite loop since
808
            // TeamController uses this code to get the database instance.
809
            let teamName = TeamController.ADMIN_NAME;
23✔
810
            let team = await this.getTeam(teamName);
23✔
811
            if (team === null) {
23!
812
                const newTeam: Team = {
23✔
813
                    id: teamName,
814
                    delivId: null, // null for special teams
815
                    githubId: null, // to be filled in later
816
                    URL: null, // to be filled in later
817
                    personIds: [], // empty for special teams
818
                    // repoName:  null, // null for special teams
819
                    // repoUrl:   null,
820
                    custom: {}
821
                };
822
                await this.writeTeam(newTeam);
23✔
823
            }
824
            teamName = TeamController.STAFF_NAME;
23✔
825
            team = await this.getTeam(teamName);
23✔
826
            if (team === null) {
23!
827
                const newTeam: Team = {
23✔
828
                    id: teamName,
829
                    delivId: null, // null for special teams
830
                    githubId: null, // to be filled in later
831
                    URL: null, // to be filled in later
832
                    personIds: [], // empty for special teams
833
                    // repoName:  null, // null for special teams
834
                    // repoUrl:   null,
835
                    custom: {}
836
                };
837
                await this.writeTeam(newTeam);
23✔
838
            }
839
            teamName = "students";
23✔
840
            team = await this.getTeam(teamName);
23✔
841
            if (team === null) {
23!
842
                const newTeam: Team = {
23✔
843
                    id: teamName,
844
                    delivId: null, // null for special teams
845
                    githubId: null, // to be filled in later
846
                    URL: null, // to be filled in later
847
                    personIds: [], // empty for special teams
848
                    // repoName:  null, // null for special teams
849
                    // repoUrl:   null,
850
                    custom: {}
851
                };
852
                await this.writeTeam(newTeam);
23✔
853
            }
854
        } catch (err) {
855
            Log.error("DatabaseController::initDatabase() - ERROR: " + err.message);
×
856
        }
857
    }
858

859
    public async writeAuth(record: Auth): Promise<boolean> {
860
        Log.trace("DatabaseController::writeAuth( " + record.personId + ", ... ) - start");
66✔
861
        const auth = await this.readSingleRecord(this.AUTHCOLL, {personId: record.personId}) as Auth;
66✔
862
        if (auth === null) {
66✔
863
            return await this.writeRecord(this.AUTHCOLL, record);
58✔
864
        } else {
865
            const query = {personId: record.personId};
8✔
866
            return await this.updateRecord(this.AUTHCOLL, query, record);
8✔
867
        }
868
    }
869

870
    /**
871
     * Find all results for a given <repoId, delivId>. This result list is not pruned,
872
     * all results are returned.
873
     *
874
     * NOTE: These are _all_ results, the deliverable deadlines are not considered.
875
     *
876
     * @param delivId
877
     * @param repoId
878
     */
879
    public async getResults(delivId: string, repoId: string): Promise<Result[]> {
880
        const start = Date.now();
177✔
881
        Log.trace("DatabaseController::getResults( " + delivId + ", " + repoId + " ) - start");
177✔
882
        const latestFirst = {"input.target.timestamp": -1}; // most recent first
177✔
883
        const results = await this.readRecords(this.RESULTCOLL, QueryKind.SLOW, false,
177✔
884
            {delivId: delivId, repoId: repoId}, latestFirst) as Result[];
885
        for (const result of results) {
177✔
886
            if (typeof (result.input as any).pushInfo !== "undefined" && typeof result.input.target === "undefined") {
923!
887
                // this is a backwards compatibility step that can disappear in 2019 (except for sdmm which will need further changes)
888
                result.input.target = (result.input as any).pushInfo;
×
889
            }
890
        }
891

892
        Log.trace("DatabaseController::getResults( " + delivId + ", " + repoId + " ) - done; #: " +
177✔
893
            results.length + "; took: " + Util.took(start));
894

895
        return results;
177✔
896
    }
897

898
    /**
899
     * Find all results for a given repoId. This is especially useful to get a complete list of all results for a repo,
900
     * given that optimizations in the broader getResults* methods often prune results to enable better performance.
901
     *
902
     * NOTE: These are _all_ results, the deliverable deadlines are not considered.
903
     *
904
     * @param repoId
905
     */
906
    public async getResultsForRepo(repoId: string): Promise<Result[]> {
907
        const start = Date.now();
7✔
908
        Log.trace("DatabaseController::getResultsForRepo( " + repoId + " ) - start");
7✔
909

910
        const latestFirst = {"input.target.timestamp": -1}; // most recent first
7✔
911
        const results = await this.readRecords(this.RESULTCOLL, QueryKind.SLOW, false, {repoId: repoId}, latestFirst) as Result[];
7✔
912
        for (const result of results) {
7✔
913
            if (typeof (result.input as any).pushInfo !== "undefined" && typeof result.input.target === "undefined") {
51!
914
                // this is a backwards compatibility step that can disappear in 2019 (except for sdmm which will need further changes)
915
                result.input.target = (result.input as any).pushInfo;
×
916
            }
917
        }
918

919
        Log.trace("DatabaseController::getResultsForRepo( " + repoId + " ) - done; #: " +
7✔
920
            results.length + "; took: " + Util.took(start));
921

922
        return results;
7✔
923
    }
924

925
    /**
926
     * For a given deliverable, find the most recent result for each <repoId, delivId> tuple. The repoId restriction
927
     * exists because in large terms the results collection can be quite large and ends up being too slow to retrieve.
928
     * But this version is better than just retrieving the last 400 results because it means the results and dashboard
929
     * views will always have at least one row for each repo. This query only includes one row per repoId, to see all
930
     * the results for a given repoId, use getResultsForRepo.
931
     *
932
     * NOTE: These are _all_ results, the deliverable deadlines are not considered.
933
     *
934
     * @param delivId
935
     */
936
    public async getResultsForDeliverable(delivId: string): Promise<Result[]> {
937
        const start = Date.now();
2✔
938
        Log.trace("DatabaseController::getResultsForDeliverable( " + delivId + " ) - start");
2✔
939

940
        const col = await this.getCollection(this.RESULTCOLL, QueryKind.FAST);
2✔
941
        const records: any[] = await (col as any).aggregate([
2✔
942
            {$match: {delivId: delivId}},
943
            {$sort: {"input.target.timestamp": -1}},
944
            {
945
                $group: {
946
                    _id: {delivId: "$delivId", repoId: "$repoId"},
947
                    doc: {$first: "$$ROOT"},
948
                }
949
            },
950
            {$replaceRoot: {newRoot: "$doc"}}
951
        ]).toArray();
952

953
        if (records === null || records.length === 0) {
2✔
954
            Log.trace("DatabaseController::readRecords(..) - done; no records found");
1✔
955
            return [];
1✔
956
        } else {
957
            for (const r of records) {
1✔
958
                delete r._id; // remove the record id, just so we cannot use it
1✔
959
            }
960
            Log.trace("DatabaseController::readRecords(..) - done; # records: " +
1✔
961
                records.length + ". took: " + Util.took(start));
962
        }
963

964
        Log.trace("DatabaseController::getResultsForDeliverable( " + delivId + " ) - done; #: " +
1✔
965
            records.length + "; took: " + Util.took(start));
966

967
        return records;
1✔
968
    }
969

970
    /**
971
     * Find the result for a given <deliverable, repo, SHA> tuple or null if such a result does not exist.
972
     *
973
     * @param delivId
974
     * @param repoId
975
     * @param sha
976
     * @param ref the branch reference; if null, it is not considered
977
     */
978
    public async getResult(delivId: string, repoId: string, sha: string, ref: string | null): Promise<Result | null> {
979
        const results = await this.getResults(delivId, repoId) as Result[];
177✔
980
        let result = null;
177✔
981
        for (const res of results) {
177✔
982
            if (ref !== null && ref === res?.input?.target?.ref && res.commitSHA === sha) {
923!
983
                // if the ref is specified, match that too
984
                result = res;
2✔
985
            } else if (res.commitSHA === sha) {
921✔
986
                // there should only be one of these <delivId, SHA> tuples, but if there are more than one this will return the last one
987
                result = res;
1✔
988
            }
989
        }
990

991
        if (result === null) {
177✔
992
            Log.trace("DatabaseController::getResult( " + delivId + ", " + repoId + ", " + Util.shaHuman(sha) + " ) - result not found");
174✔
993
        }
994
        return result;
177✔
995
    }
996

997
    /**
998
     * Gets the result for a commit and a deliverable. Only returns one record because multiple executions on the same <SHA, delivId>
999
     * tuple cause the db record to be updated.
1000
     *
1001
     * @param {string} commitURL
1002
     * @param {string} delivId
1003
     * @returns {Promise<Result>}
1004
     */
1005
    public async getResultFromURL(commitURL: string, delivId: string): Promise<Result | null> {
1006
        // return await this.readSingleRecord(this.RESULTCOLL, {commitURL: commitURL, delivId: delivId}) as Result;
1007
        const records = await this.readRecords(this.RESULTCOLL, QueryKind.FAST, false,
2✔
1008
            {commitURL: commitURL, delivId: delivId}) as Result[];
1009

1010
        if (records.length > 1) {
2!
1011
            // This should not happen, but we want to know for sure
1012
            Log.warn("DatabaseController::getResultFromURL( " + commitURL + ", " + delivId + " ) - multiple results returned");
×
1013
        }
1014
        if (records.length > 0) {
2✔
1015
            return records[0];
1✔
1016
        }
1017
        return null;
1✔
1018
    }
1019
}
1020

1021
export enum QueryKind {
1✔
1022
    FAST = "fast",
1✔
1023
    SLOW = "slow",
1✔
1024
    WRITE = "write"
1✔
1025
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc