read24 - Server: Quiz Status and Data Layer Refactor

July 8, 2020

Roger Ngo

There is a lot of work to be done today. The agenda has two tasks, with the second one being quite large: a data layer refactor.

Updating Quiz Status

First, I'm going to warm up with tackling the first task, and that is to implement a new route /quiz/status. This is the endpoint which will update the status of a quiz.

Recall that there are 3 possible states a quiz session (tied to a QuizToken) can be in:

export enum QuizStatus {
    Incomplete = 0,
    Abandoned = 1,
    Completed = 2
};

This endpoint, POST /quiz/status may support a payload containing quizToken, and status

{
    quizToken,
    status
}

I can use the provided quizToken to update the particular QuizToken entry in the database with the new status value. The status value is a number representing the QuizStatus.

Notice that since i want to perform a database update, I should extend memory.ts to include a new update method. I think for now, defining it with this signature is good:

update(tableName: string, data: DataObject, whereFunc: (o: DataObject) => boolean): number;

I decide to make whereFunc mandatory to prevent the scenario of the developer accidentally triggering an update across all elements in the table. Now, the update function will overwrite all matching entries found with whereFunc for a table with the object data. This updatefunction will also return the number of rows updated.

public update(tableName: string, data: DataObject, whereFunc: (o: DataObject) => boolean) {
    if (db.data[tableName].length === 0)
        return 0;

    const rows = db.data[tableName].filter(whereFunc);

    for (const row of rows) {
        const id = row.id;

        Object.assign(row, data);

        // Keep the old id
        row.id = id;
    }

    return rows.length;
}

As for the implementation of the route itself, it is quite simple. The quizToken, and status are read from the request body. Then the quizToken is used to retrieve the corresponding QuizToken entry. The entry's status is updated with the provided status value from the user-supplied request payload.

app.post('/quiz/status', (req, res) => {
    const quizToken = req.body.quizToken;
    const status = parseInt(req.body.status, 10);

    const rowsUpdated = MemoryDb.update('quizTokens', {
        id: 1,
        token: quizToken,
        status
    }, q => q.token === quizToken);

    if (rowsUpdated === 0)
        return res.status(304).send({message: 'Not modified.'});

    const newRow = MemoryDb.select('quizTokens', q => q.token === quizToken);

    return res.status(200).json(newRow);
});

What is then returned is the object representation of the modified QuizToken in the database. If there was nothing found to update, then a HTTP 304 is sent instead.

Let's test with the following request. We want to update a quiz to be Completed. This means that the status value will be 2.

POST localhost:3000/quiz/status
{
    "quizToken": "1be513fa-22df-40c5-88a3-761f0aa7423d",
    "status": 2
}

We will then receive a response:

{
    "id": 1,
    "status": 2,
    "bookId": 1,
    "studentId": 1,
    "token": "1be513fa-22df-40c5-88a3-761f0aa7423d"
}

Now read24 is able to support calls to take a quiz to different states.‌

Data Layer Refactor

It is becoming pretty obvious that the database operations for the server application is beginning to feel awkward. I am also a bit annoyed in that I have to specify an id value for an object which hasn't even been inserted into the database yet! Using MemoryDb is great and all, but its being accessed directly by route implementations, which can lead to issues in maintenance if schema changes.

Anyway, I think the point is clear: I need to refactor the components used for database management

The ideal access to me for a data layer is to have it behave more like a traditional ORM. The feature I am wanting the most is to treat database rows as individual objects which encapsulate database read, and write operations as their own implementation-specific methods.

Whereas before an object type like QuizToken was implemented like:

export interface QuizToken {
    id: number;
    studentId: number;
    bookId: number;
    token: string;
    status: QuizStatus;
};

QuizToken can be implemented to behave more like an object which can handle its own read, and write operations. To generalize this, a BaseResource class can be created for extension by other data objects. The BaseResource class will consist of the fundamental database operations which child classes may get for free.

Such an implementation may look like this:

import { DataRow, BaseResource } from "../db/types";
import { MemoryDb } from "../db/memory";

export interface QuizTokenType extends DataRow {
    studentId: number;
    bookId: number;
    token: string;
    status: QuizStatus;
};

export class QuizToken
    extends BaseResource
    implements QuizTokenType {

    public studentId: number;
    public bookId: number;
    public token: string;
    public status: QuizStatus;

    constructor(initialProperties?: {[properties: string]: any}) {
        super('quizTokens');

        if (initialProperties) {
            this.studentId = initialProperties.studentId;
            this.bookId = initialProperties.bookId;
            this.token = initialProperties.token;
            this.status = initialProperties.status;
        }
    }

    public static findByToken(token: string) {
        const id = (MemoryDb.select('quizTokens', (o: DataRow) => (o as QuizToken).token === token)[0] as QuizToken).id;

        return new QuizToken().load(id);
    }
}

Then it can be used to create a QuizToken object like this:

app.post('/quiz/book', (req, res) => {
    const bookId = req.body.bookId;
    const studentId = req.body.studentId;
    const token = uuid.v4();

    const quizToken = new QuizToken({
        status: QuizStatus.Incomplete,
        bookId,
        studentId,
        token
    });

    quizToken.insert();

    const quiz = buildQuiz(bookId);

    // Associate each question with the quiz
    for(const el of quiz) {
        const quizQuestion = new QuizQuestion({
            quizToken: token,
            questionId: el.question.id
        });

        quizQuestion.insert();
    }

   return res.status(200).json({token, quiz});
});

Notice how the insert function can be used as it exists in QuizToken for free through BaseResource.

So what does BaseResource look like? Let's create a types.ts under db. Within this, I will define a new base type called DataRow which will represent a data object from a database row with minimal (and required) properties:

export interface DataRow {
    id: number;
    dateCreated?: number;
    dateUpdated?: number;
}

What this is saying is that every data row in the database should minimally contain 3 properties. id is required as it is the default primary key for every database element.‌

Now, once I have DataRow as the base type, all other types such as QuizTokenType may inherit from it. BaseResource may then use it to perform read, and write operations.

import { MemoryDb } from "./memory";

export interface DataRow {
    id: number;
    dateCreated?: number;
    dateUpdated?: number;
}

export abstract class BaseResource implements DataRow {
    public id: number;
    public dateCreated: number;
    public dateUpdated: number;

    private _tableName: string;

    constructor(tableName: string) {
        this._tableName = tableName;
    }

    public load(id: number) {
        const found = MemoryDb.find(this._tableName, id);

        if (!found)
            throw new Error(`Did not find a row with ID value: ${this.id}`);

        Object.assign(this, found);

        return this;
    }

    public insert() {
        if (this.id > 0)
            throw new Error(`Cannot insert this row because it already has an ID value: ${this.id}.`);

        this.dateCreated = Date.now();

        const toSave: DataRow = { id: 0 };

        for(const prop in this) {
            if (typeof this[prop] === 'function' || prop === '_tableName') {
                continue;
            }

            toSave[prop.toString()] = this[prop];
        }

        MemoryDb.insert(this._tableName, toSave);

        return this;
    }

    public update(whereFunc: (o: DataRow) => boolean) {
        if (this.id === 0)
            return this;
        
        this.dateUpdated = Date.now();

        const toSave: DataRow = { id: 0 };

        for(const prop in this) {
            if (typeof this[prop] === 'function' || prop === '_tableName') {
                continue;
            }

            toSave[prop.toString()] = this[prop];
        }

        MemoryDb.update(this._tableName, toSave, whereFunc);

        return this;
    }

    public json() {
        const obj = {};

        for(const prop in this) {
            if (typeof this[prop] === 'function'
                || prop === '_tableName'
                || prop === 'dateCreated'
                || prop === 'dateUpdated'
            ) {
                continue;
            }

            obj[prop.toString()] = this[prop];
        }

        return obj;
    }
} 

Notice how I have decided to indirectly use MemoryDb here. In the future, this should be not reliant on MemoryDb, but an abstract database connector so that it won't matter whether or not the database is in-memory, disk, or in the cloud.

The insert, and update methods will iterate through all properties which are not of a function type, nor if it is part of the private properties which BaseResource needs as meta-information such as _tableName.

Once a new object is constructed by iterating through properties, then the object will be passed to MemoryDb for writing.

I can then refactor all the other routes and data objects to leverage this. For example, here is the complete refactor for the /quiz routes using the refactored data layer.

import { IRouter } from "express";
import * as uuid from "uuid";
import { QuizToken, QuizStatus } from "../models/quiztoken";
import { Question } from "../models/question";
import { Choice } from "../models/choice";
import { QuizQuestion } from "../models/quiz_question";
import { StudentAnswer } from "../models/student_answer";
import { Rating } from "../models/rating";

function buildQuiz(bookId: number) {
    const result = Question.listByBookId(bookId)
        .map(q => ({
            question: q.json(),
            choices: Choice.listByQuestionId(q.id).map(c => c.json())
        }));

    return result;
}

export function mountQuiz(app: IRouter) {
    app.post('/quiz/book', (req, res) => {
        const bookId = req.body.bookId;
        const studentId = req.body.studentId;
        const token = uuid.v4();

        const quizToken = new QuizToken({
            status: QuizStatus.Incomplete,
            bookId,
            studentId,
            token
        });
        quizToken.insert();

        const quiz = buildQuiz(bookId);

        // Associate each question with the quiz
        for(const el of quiz) {
            const quizQuestion = new QuizQuestion({
                quizToken: token,
                questionId: el.question.id
            });

            quizQuestion.insert();
        }

       return res.status(200).json({token, quiz});
    });

    app.post('/quiz/book/question', (req, res) => {
        const quizToken = req.body.quizToken;
        const choiceId = req.body.choiceId;

        if (!quizToken || !choiceId)
            return res.status(400).json({message: 'Must provide both the quiz token, and choice.'});

        const qt = QuizToken.findByToken(quizToken);
        const c = new Choice().load(choiceId);

        const studentAnswer = new StudentAnswer({
            quizToken: qt.token,
            studentId: qt.studentId,
            questionId: c.questionId,
            choiceId: c.id
        });
        studentAnswer.insert();

        return res.status(200).json(studentAnswer.json());
    });

    app.post('/quiz/book/rate', (req, res) => {
        const quizToken = req.body.quizToken;
        const rating = req.body.rating;

        const r = new Rating({
            quizToken,
            rating
        });
        r.insert();

        return res.status(200).json(r.json());
    });

    app.post('/quiz/status', (req, res) => {
        const quizToken = req.body.quizToken;
        const status = parseInt(req.body.status, 10);

        const original = QuizToken.findByToken(quizToken);
        original.token = quizToken;
        original.status = status;

        original.update(q => (q as QuizToken).token === quizToken);

        return res.status(200).json(original.json());
    });
}

Could the data layer be better? Of course, but that's a task for another day.