Complete guide to using TypeORM and TypeScript for data persistence in Node.js module

; Date: Tue Jul 16 2019

Tags: Node.JS »»»» TypeScript

TypeORM is an advanced object-relations-management module that runs in Node.js. As the name implies, TypeORM is meant to be used with TypeScript. In this article we'll learn about using TypeORM to set up Entity objects to store data in a database, how to use a CustomRepository instance to manipulate a database table, and to use Relations between Entity instances to simulate database joins.

To start one must have read the previous two articles in this series:

  1. Setting up the Typescript compiler to integrate with Node.js development
  2. Creating either CommonJS or ES6 modules for Node.js packages using Typescript

In this article we'll create a simple TypeScript module for Node.js to handle storing data in a database for an application. The concept we're following is a University Registrar office needs a database and corresponding applications to store data about students and offered courses.

Because we're using TypeScript it is natural to use TypeORM to simplify managing the database. We'll be creating two entities - Student and OfferedClass - with a corresponding CustomRepository instance for each. The CustomRepository class will provide high level functions for the database.

Initializing the module package.json and test directory

[return to top]

As we do with all Node.js projects, we first use npm init or yarn init to initialize the directory, and do other initialization.

What we're initializing is a module to handle the database for this conceptualized University Registrar application. We'll create the code for that later, at this stage we're just laying the foundation.

Create a directory ts-example and inside it a directory registrar.

$ mkdir ts-example
$ cd ts-example
$ mkdir registrar
$ cd registrar
$ npm init
.. answer questions

That initializes the package.json.

$ npm install @types/node --save-dev
$ npm install typescript ts-node --save-dev
$ npm install typeorm sqlite3 reflect-metadata --save

Most of these packages were discussed in the earlier articles. The typeorm package of course provides the TypeORM library. For this example we'll use SQLite3 to store the database, hence the sqlite3 package. Finally the reflect-metadata package is required by TypeORM.

Create a file named tsconfig.json containing:

{
    "compilerOptions": {
        "lib": [ "es5", "es6", "es7",
                 "es2015", "es2016",
                 "es2017", "es2018",
                 "esnext" ],
        "target": "es2017",
        "module": "commonjs",
        "moduleResolution": "Node",
        "outDir": "./dist",
        "rootDir": "./lib",
        "declaration": true,
        "declarationMap": true,
        "inlineSourceMap": true,
        "inlineSources": true,
        "emitDecoratorMetadata": true,
        "experimentalDecorators": true
    }
}

This is just a little different than in "Creating either CommonJS or ES6 modules for Node.js packages using Typescript". The parameters mean:

  • The target line says to output ES2017 code, which we need because that version supports for async/await functions.
  • The module line describes the output module format which will be used, commonjs matching the decision to use the CommonJS module format.
  • The moduleResolution parameter says to look up modules in node_modules directories just like NodeJS does.
  • The outDir parameter says to compile files into the named directory, and the rootDir parameter says to compile the files in the named directory.
  • The declaration and declarationMap parameters says to generate declaration files.
  • The inlineSourceMap and inlineSources say to generate source-map data inside JavaScript source files.
  • The emitDecoratorMetadata and experimentalDecorators are used by TypeORM when generating code.

What that means is our source code will be in lib and TypeScript will compile it into dist.

We have a little change to make in package.json:

{
  ...
  "main": "dist/index.js",
  "types": "./dist/index.d.ts",
  "type": "commonjs",
  "scripts": {
    "build": "tsc",
    "watch": "tsc --watch",
    "test": "cd test && npm run test"
  },
  ...
}

The build script simply runs tsc to compile the sources. The test script changes to the test directory to run the test suite.

The main for this package is the generated index module, specifically dist/index.js. With the tsconfig.json shown earlier, TypeScript source is in the lib directory, and therefore the main interface to the module would be in lib/index.ts. The TypeScript compiler compiles lib/index.ts to dist/index.js. The type attribute is new to NodeJS 12.x and lets us declare the kind of modules used in this package. If dist/index.js were instead in the ES6 Module format, the attribute value would be module instead.

The types field declares to the world that this module contains type definitions. It is good form to automatically generate type definitions, which the tsconfig.json file shown earlier does, and then make sure the world knows that type definitions are included.

Set up test directory

[return to top]

It is useful to create a unit test suite alongside application code. There are many approaches to unit testing, so take this as one person's opinion.

In the ts-example/registrar directory, create a directory named test and initialize a new package.json.

$ mkdir test
$ cd test
$ npm init
.. answer questions
$ npm install chai mocha --save-dev

We'll use Chai and Mocha to write the tests. Because we've configured TypeScript to generate a CommonJS module we'll just use Mocha in the default way. Mocha currently supports testing CommonJS modules, and using Mocha to test ES6 modules requires jumping through a couple hoops.

Now edit the package.json in the test directory and make it look like so:

{
    "name": "registrar-test",
    "version": "1.0.0",
    "description": "Test suite for student registrar library",
    "main": "index.js",
    "scripts": {
        "pretest": "cd .. && npm run build",
        "test": "rm -f registrardb.sqlite && mocha ./index"
    },
    "author": "David Herron <david@davidherron.com>",
    "license": "ISC",
    "devDependencies": {
        "chai": "^4.2.0",
        "mocha": "^6.1.4"
    },
    "dependencies": {}
}

For the Mocha and Chai version numbers, use whatever is the latest. The important thing here is the two scripts. To run the tests we use the mocha command, but before running the tests we want to make sure the source code is rebuilt. Hence the pretest script goes to the parent directory and runs the build script.

Since we haven't written test code, or application code, we cannot yet run anything. Patience, we'll be running tests before the end of the article.

index.ts -- Main programmatic interface to Registrar module

[return to top]

Having laid the foundation we can start writing code to handle the database. In the lib directory create a file named index.ts. Remember that lib/index.ts gets compiled to dist/index.js and will serve as the main entry point to this module.

import "reflect-metadata";
import { createConnection, Connection } from "typeorm";
import { Student } from './entities/Student';
export { Student } from './entities/Student';
import { StudentRepository } from './StudentRepository';
export { StudentRepository } from './StudentRepository';
import { OfferedClassRepository } from './OfferedClassRepository';
export { OfferedClassRepository } from './OfferedClassRepository';
import { OfferedClass } from './entities/OfferedClass';
export { OfferedClass } from './entities/OfferedClass';

var  _connection: Connection;

export async function connect(databaseFN: string) {
    _connection = await createConnection({
        type: "sqlite",
        database: databaseFN,
        synchronize: true,
        logging: false,
        entities: [
            Student, OfferedClass
        ]
     });
}

export function connected() { return typeof _connection !== 'undefined'; }

export function getStudentRepository(): StudentRepository {
    return _connection.getCustomRepository(StudentRepository);
}

export function getOfferedClassRepository(): OfferedClassRepository {
    return _connection.getCustomRepository(OfferedClassRepository);
}

If this doesn't look like much, consider that all the functionality lives in the files being imported here. Think about how to structure the API covering a large database. Do you want to bring every last function into one module? No, that one module would be unwieldy. Instead you'd modularize things, as seen here. All that's here is functions to manage the database connection. The classes named StudentRepository and OfferedClassRepository contain the CRUD operations for the corresponding tables.

These two classes are to be instances of the TypeORM CustomRepository class. CustomRepository itself is an instance of Repository, providing base functionality for handling the database table corresponding to a TypeORM Entity.

Normally you'd use the base Repository class like so:

const studentRepository = getRepository(Student);

But we wanted to add some custom functions to our application. Therefore we'll implement whats's called a Custom Repository class. The getStudentRepository function handles generating the custom repository implementation.

The other important piece of code is the connect function which sets up the database connection. This should be called just after the application launches. It simply initializes the database connection is using createConnection. That function takes a descriptor object used to connect with the actual database, and do other configuration.

TypeORM of course supports many kinds of databases, and for simplicity we're using SQLite3 because it requires no setup of a database server.

The entities array is how we tell TypeORM the available object types. It creates a database table matching each Entity, defining the table schema from Entity fields.

In this case the entities array contains the Entity classes that will be defined later. Any time we add another Entity class we have to remember to update this file so TypeORM knows about the Entity. It is also possible to pass a wildcard filename so that TypeORM automatically pulls in all Entities.

It's possible to spend a long time on debugging if you mess up the entities configuration. TypeORM will tell you it cannot find metadata for the entity, and it'll be very unclear why that's the case. If we're going to do as shown here, and pass Entity's in the entities array then we must do it carefully. It must be the Entity class reference, and not something else like the module containing the Entity class.

Creating TypeORM Entity classes, and CRUD methods

[return to top]

In the previous section we implemented the main API to the Registrar database. But we referenced several classes that provide the bulk of the functionality.

In this application we're using two main features of TypeORM:

  1. Entity classes -- Maps a simple object definition to a database table
  2. Custom repository classes -- Useful API functions for manipulating the database tables

Each Repository class is associated with an Entity class, and its functions therefore manipulate the table associated with that Entity. By implementing a Custom Repository class we will create additional functions.

TypeORM Entities

[return to top]

In TypeORM an Entity maps between a TypeScript class and a database table. You create an Entity by adding the @Entity() annotation at the top of the class definition, and then for each field in the class adding a @Column (or similar) annotation. Those annotations give TypeORM the information necessary to set up the database table.

It appears that we are not to add any functions to the Entity class. Instead we simply add field definitions. Behind the scenes TypeORM must be setting up getter and setter functions, and other support functions.

The Student entity

[return to top]

In the lib directory create a directory named entities. We'll put all Entity class definitions in this directory. Then create a file named lib/entities/Student.ts

import { 
    Entity, Column, PrimaryGeneratedColumn,
    ManyToMany, JoinTable
} from "typeorm";
import { OfferedClass } from './OfferedClass';

@Entity()
export class Student {
    @PrimaryGeneratedColumn()  id: number;
    @Column({
        length: 100
    })  name: string;
    @Column("int")  entered: number;
    @Column("int")  grade: number;
    @Column()  gender: string;

    @ManyToMany(() => OfferedClass, oclass => oclass.students)
    @JoinTable()
    classes: OfferedClass[];
}

As we said earlier @Entity() says the class will be treated by TypeORM as an Entity. To get TypeORM to recognize the Entity, we must configure the entities array in the connection descriptor such that TypeORM will find the Entity class.

We have five fields:

  • id is the primary key for the Student table
  • name is the student's name
  • entered is the year the student entered the university
  • grade is the class year they're currently in
  • gender indicates whether they're male or female. This was meant to be an enum named Gender but SQLite3 doesn't seem to support enum fields.

Entity Relationships in TypeORM - ManyToMany

[return to top]

There is an additional field, students, that requires deeper explanation. It has two annotations, @ManyToMany and @JoinTable. This is an example of an Entity Relationship, one of TypeORM's features.

With entity relationships, TypeORM automagically handles joins between tables. In this case we want to support the case where

  1. A Student can enroll in multiple classes
  2. An OfferedClass can have multiple students enrolled in the class

In other words, many Student instances refer to many OfferedClass instances, and vice versa. This is what TypeORM calls a bidirectional ManyToMany relationship.

The @JoinTable annotation here indicates that the Student class is the owner of the relationship. Behind the scenes this annotation causes the creation of a table, the Join Table, that helps connect instances of Student and OfferedClass together.

With ManyToMany, the @ManyToMany annotation is required for both Entity's. Let's now look at the OfferedClass entity.

The OfferedClass entity

[return to top]

Obviously our students will appreciate being able to register for classes. So far all we have is a list of students, and no means for them to sign up for anything. What kind of Registrar operation are we?

To rectify this we need to set up an additional table or tables to hold information about classes. For this we could pull out a copy of Enterprise Architect and design up a whole class hierarchy. For the sake of brevity let's do the simple thing. We'll add one Entity, named OfferedClass, and then use TypeORM annotations to assign relationships between Student instances and OfferedClass instances.

Create file named lib/entities/OfferedClass.ts

import { 
    Entity, 
    Column, 
    PrimaryColumn, 
    OneToMany,
    ManyToMany
} from "typeorm";
import { Student } from './Student';

@Entity()
export class OfferedClass {

    @PrimaryColumn({
        length: 10
    }) code: string;
    @Column({
        length: 100
    })  name: string;
    @Column("int")  hours: number;

    @ManyToMany(type => Student, student => student.classes)
    students: Student[];
}

This entity records data about classes offered by the University. The fields are:

  • code is the class number, e.g. CS101
  • name is the descriptive name for the class, such as Computer Science 101
  • students is the other side of the @ManyToMany relationship

The @ManyToMany annotation on the students field is the other half to the corresponding annotation in the Student Entity. The ManyToMany relation requires both sides to be declared.

Custom Repository classes to manage Student and OfferedClass entities - CRUD operations and more

[return to top]

Now that we have the database entities defined, we need some useful functions to manage these entities. As we said earlier, TypeORM out of the box provides a default Repository class with a bunch of useful functions. But we want RegistrarDB to provide a higher layer of abstraction.

In index.ts we already declared there will be two classes, StudentRepository and OfferedClassRepository. These are to be CustomRepository instances that provide the functions we desire.

StudentRepository - CRUD operations for the Student entity

[return to top]

Therefore the StudentRepository class will implement CRUD functions (create, read, update and delete) to manage Student instances, along with a couple other useful functions.

Create a file named lib/StudentRepository.ts:

import { EntityRepository, Repository, getRepository } from "typeorm";
import { Student } from "./entities/Student";
import * as util from 'util';

export type GenderType = "male" | "female";

export enum Gender {
    male = "male", female = "female"
}

@EntityRepository(Student)
export class StudentRepository extends Repository<Student> {
    ...
}

We'll be adding a lot more to this, but this is the beginning structure. We have imported a couple things from typeorm as well as the Student entity. As we said, the gender field in Student was meant to be an enum named Gender, and we've defined it here.

The main feature of this module is the StudentRepository class. The instructions to implement a custom Repository is to extend the definition of the Repository class in this way, and to use the EntityRepository annotation as shown here.

By extending the Repository class, the StudentRepository class automatically has access to functions from the Repository class. Hence our functions will build upon the Repository API, and follow the pattern set by that API.

Add this function inside the StudentRepository class:

async createAndSave(student: Student): Promise<number> {
    let stud = new Student();
    stud.name = student.name;
    stud.entered = normalizeNumber(student.entered, 'Bad year entered');
    stud.grade = normalizeNumber(student.grade, 'Bad grade');
    stud.gender = student.gender;
    await this.save(stud);
    return stud.id;
}

The name createAndSave is suggested in the TypeORM documentation. As implied, this function handles creating a Student object and then saving it to the database. This is the first of the CRUD operations.

We create a new Student object to be certain the object saved to the database has only the fields defined in Student. the object that's passed could easily have other fields and still be type compatible with Student.

After setting up the Student object, we save it to the database. TypeORM's role is to convert this to the underlying database connection which was configured.

Add these functions inside the StudentRepository class:

async allStudents(): Promise<Student []> {
    let students = await this.find();
    return students;
}

async findOneStudent(id: number):
            Promise<Student> {
    let student = await this.findOne({ 
        where: { id: id }
    });
    if (!StudentRepository.isStudent(student)) {
        throw new Error(`Student id ${util.inspect(id)} did not retrieve a Student`);
    }
    return student;
}

In findOneStudent we have the next CRUD operation, reading a Student object from the database. The findOne method is one way to search the database to retrieve data. The where clause lets us describe how to select from the database the items we want to retrieve.

Add this function inside the StudentRepository class:

async updateStudent(id: number, student: Student):
            Promise<number> {
    if (typeof student.entered !== 'undefined') {
        student.entered = normalizeNumber(student.entered, 'Bad year entered');
    }
    if (typeof student.grade !== 'undefined') {
        student.grade = normalizeNumber(student.grade, 'Bad grade');
    }
    if (!StudentRepository.isStudentUpdater(student)) {
        throw new Error(`Student update id ${util.inspect(id)} did not receive a Student updater ${util.inspect(student)}`);
    }
    await this.manager.update(Student, id, student);
    return id;
}

In updateStudent we have the next CRUD operation, updating a Student object. We need the ID for the student to update, then an object that will allow updating the object. In TypeORM the update method lets us specify a partial object, and it will update the fields which are set. The isStudentUpdater method checks to see it has fields that are valid for updating Student objects.

Add this function inside the StudentRepository class:

async deleteStudent(student: number | Student) {
    if (typeof student !== 'number'
     && !StudentRepository.isStudent(student)) {
        throw new Error('Supplied student object not a Student');
    }
    await this.manager.delete(Student, 
            typeof student === 'number' 
                ? student : student.id);
}

In deleteStudent we have our last CRUD operation, deleting a Student. We can use either the ID number or the Student object (from which we get the ID number).

Add this function outside the body of the StudentRepository class:

export function normalizeNumber(
            num: number | string, errorIfNotNumber: string)
        : number {
    if (typeof num === 'undefined') {
        throw new Error(`${errorIfNotNumber} -- ${num}`);
    }
    if (typeof num === 'number') return num;
    let ret = parseInt(num);
    if (isNaN(ret)) {
        throw new Error(`${errorIfNotNumber} ${ret} -- ${num}`);
    }
    return ret!;
}

This function is used in a couple places. Depending on the data source, for example a FORM submission from a web browser, we might receive a string that is actually a number. This function accommodates that possibility using parseInt.

Type checking functions for Students

[return to top]

TypeScript does not support run-time type checking. To have run-time type checking, matching the compile-time type checking TypeScript gives us, we have to implement it ourselves. That's another difference between TypeScript and languages like Java or C#.

To implement run-time type checking, TypeScript wants us to implement functions called type guards. These functions take an object, and are supposed to test the objects characteristics to see if it is of the correct type, then return a boolean indicator.

Add these static functions to the StudentRepository class:

static isStudent(student: any): student is Student {
    return typeof student === 'object'
        && typeof student.name === 'string'
        && typeof student.entered === 'number'
        && typeof student.grade === 'number'
        && StudentRepository.isGender(student.gender);
}

static isStudentUpdater(updater: any): boolean {
    let ret = true;
    if (typeof updater !== 'object') {
        throw new Error('isStudentUpdater must get object');
    }
    if (typeof updater.name !== 'undefined') {
        if (typeof updater.name !== 'string') ret = false;
    }
    if (typeof updater.entered !== 'undefined') {
        if (typeof updater.entered !== 'number') ret = false;
    }
    if (typeof updater.grade !== 'undefined') {
        if (typeof updater.grade !== 'number') ret = false;
    }
    if (typeof updater.gender !== 'undefined') {
        if (!StudentRepository.isGender(updater.gender)) ret = false;
    }
    return ret;
}

static isGender(gender: any): gender is Gender {
    return typeof gender === 'string'
        && (gender === 'male' || gender === 'female');
}

Remember we said earlier the gender field was meant to be an enum. We have the enum Gender defined at the top, and the isGender function verifies whether a string matches the allowable values in enum Gender.

Look close and you'll see isGender has a return type gender is Gender. What other language has a return type like that? This is what TypeScript describes as a type predicate. The TypeScript documentation doesn't explain this further. But it's clear from the context the "object is Class" predicate is used to indicate what the words say, meaning an indication the object matches that class.

The isStudent function checks an object to see if it matches the shape of the Student object. This is to be used when we expect the object to have all the fields corresponding to Student.

By contrast the isStudentUpdater is meant to be used in a context where the object will have some of the fields in the Student class. In both cases we're determining that the field types match the field types in the Student class.

OfferedClassRepository -- CRUD operations for, and testing, the OfferedClass entity

[return to top]

To follow the pattern already set, create a file named lib/OfferedClassRepository.ts. This will handle the OfferedClass table.

import {
    EntityRepository, 
    Repository, 
    getRepository 
} from "typeorm";
import { 
    OfferedClass 
} from './entities/OfferedClass';
import { 
    normalizeNumber, 
    StudentRepository 
} from './StudentRepository';
import { 
    getStudentRepository 
} from './index';
import * as util from 'util';
import * as yaml from 'js-yaml';
import * as fs from 'fs-extra';


@EntityRepository(OfferedClass)
export class OfferedClassRepository extends Repository<OfferedClass> {
    ...
}

This is like what we put at the top of StudentRepository.ts but with a couple of additions. Namely we're importing js-yaml and fs-extra. We'll use these to read in a class database. To support these modules type:

$ npm install --save js-yaml fs-extra
$ npm install --save-dev @types/js-yaml

The first test case we'll write will initialize the RegistrarDB with class descriptions that come from a YAML file. The theory we'll follow is that occasionally the Registrar will add a class, delete a class, or change something about a class. While they could do it from the CRUD API's, they could also use a YAML file.

In the OfferedClassRepository class add these functions:

async updateClasses(classFN: string) {

    const yamlText = await fs.readFile(classFN, 'utf8');
    const offered = yaml.safeLoad(yamlText);

    if (typeof offered !== 'object'
     || !Array.isArray(offered.classes)) {
        throw new Error(`updateClasses read incorrect data file from ${classFN}`);
    }

    let all = await this.allClasses();
    for (let cls of all) {
        let stillOffered = false;
        for (let ofrd of offered.classes) {
            if (ofrd.code === cls.code) {
                stillOffered = true;
                break;
            }
        }
        if (!stillOffered) {
            this.deleteOfferedClass(cls.code);
        }
    }
    for (let updater of offered.classes) {
        if (!OfferedClassRepository
                    .isOfferedClassUpdater(updater)) {
            throw new Error(`updateClasses found classes entry that is not an OfferedClassUpdater ${util.inspect(updater)}`);
        }
        let cls;
        try { 
            cls = await this.findOneClass(updater.code); 
        } catch (e) { cls = undefined }
        if (cls) {
            await this.updateOfferedClass(updater.code, updater)
        } else {
            await this.createAndSave(updater)
        }
    }
    
}

static isOfferedClass(offeredClass: any): offeredClass is OfferedClass {
    return typeof offeredClass === 'object'
        && typeof offeredClass.code === 'string'
        && typeof offeredClass.name === 'string'
        && typeof offeredClass.hours === 'number';
}

static isOfferedClassUpdater(updater: any): boolean {
    let ret = true;
    if (typeof updater !== 'object') {
        throw new Error('isOfferedClassUpdater must get object');
    }
    if (typeof updater.code !== 'undefined') {
        if (typeof updater.code !== 'string') ret = false;
    }
    if (typeof updater.name !== 'undefined') {
        if (typeof updater.name !== 'string') ret = false;
    }
    if (typeof updater.hours !== 'undefined') {
        if (typeof updater.hours !== 'number') ret = false;
    }
    return ret;
}

The updateClasses function reads in a YAML file that must have a classes array. The array will have objects that match the OfferedClassUpdater pattern. There is also two type guard functions, isOfferedClass and isOfferedClassUpdater for testing objects.

Because a YAML file can be anything, we have to test that the file contains what we expect and otherwise throw an error.

Because objects in a YAML file could be anything, we do not have any type information to help. But that's why we wrote isOfferedClassUpdater. With this we can test the objects arriving from the YAML file.

The first stage is to detect class instances that are not listed in the YAML file. Such a class would therefore be deleted from the course catalog, because it no longer exists. We detect these classes by looping over all existing classes, and if it is not listed in the YAML then we know it should be deleted.

The second stage is to either add a new class, or update an existing class, from the data in the YAML file.

BTW, we had intended to test this function on its own, but because it refers to the other CRUD functions we'll have to go ahead and implement them.

In the OfferedClassRepository class add this function:

async allClasses(): Promise<OfferedClass []> {
    let classes = await this.find({
        relations: [ "students" ]
    });
    return classes;
}

With allClasses we retrieve all the OfferedClass instances.

The bit with relations tells TypeORM to load the data in the relationship, if any. For some reason TypeORM seems to believe that the data in a relationship is optional, and doesn't always need to be loaded. To cause relationship data to load, you pass in this relations field containing an array of strings naming which relations you're interested in loading.

When loading an OfferedClass we want to always load the Students relationship, and hence we have to remember to always list this relations field.

The difference is:

  • With no relations: Only the code and name and hours fields are loaded.
  • With relations: Those fields plus the students field is an array containing students associated with the given OfferedClass.

In the OfferedClassRepository class add this function:

async createAndSave(offeredClass: OfferedClass)
            : Promise<any> {
    let cls = new OfferedClass();
    cls.code = offeredClass.code;
    cls.name = offeredClass.name;
    cls.hours = normalizeNumber(offeredClass.hours, 'Bad number of hours');
    if (!OfferedClassRepository
            .isOfferedClass(cls)) {
        throw new Error(`Not an offered class ${util.inspect(offeredClass)}`);
    }
    await this.save(cls);
    return cls.code;
}

With createAndSave we add a new OfferedClass to the database. This is similar to the same function in StudentsRepository.

In the OfferedClassRepository class add this function:

async findOneClass(code: string)
        : Promise<OfferedClass> {
    let cls = await this.findOne({ 
        where: { code: code },
        relations: [ "students" ]
    });
    if (!OfferedClassRepository.isOfferedClass(cls)) {
        throw new Error(`OfferedClass id ${util.inspect(code)} did not retrieve a OfferedClass`);
    }
    return cls;
}

With findOneClass we find one OfferedClass by the code name. We have again added the relations field to the options to make sure and pull in relationship data. This is similar to the corresponding function in StudentsRepository.

In the OfferedClassRepository class add this function:

async updateOfferedClass(
                code: string,
                offeredClass: OfferedClass)
            : Promise<any> {
        if (typeof offeredClass.hours !== 'undefined') {
            offeredClass.hours = normalizeNumber(
                    offeredClass.hours, 'Bad number of hours');
        }
        if (!OfferedClassRepository
                .isOfferedClassUpdater(offeredClass)) {
            throw new Error(`OfferedClass update id ${util.inspect(code)} did not receive a OfferedClass updater ${util.inspect(offeredClass)}`);
        }
        await this.manager.update(OfferedClass,
                    code, offeredClass);
        return code;
    }

With updateOfferedClass we are taking an OfferedClassUpdater object, and using that to update the entry in the database. The TypeORM update function takes care of selectively updating an item based on which fields are set or not set. This is similar to the corresponding function in StudentsRepository.

In the OfferedClassRepository class add this function:

async deleteOfferedClass(
                offeredClass: string | OfferedClass) {
    if (typeof offeredClass !== 'string'
        && !OfferedClassRepository.isOfferedClass(offeredClass)) {
        throw new Error('Supplied offeredClass object not a OfferedClass');
    }
    await this.manager.delete(OfferedClass,
        typeof offeredClass === 'string'
                ? offeredClass : offeredClass.code);
}

With deleteOfferedClass we attempt to delete an OfferedClass instance. This is similar to the corresponding function in StudentsRepository.

Next, we want to support enrolling a Student in an OfferedClass.

In the OfferedClassRepository class add this function:

async enrollStudentInClass(
            studentid: any, code: string) {
    let offered = await this.findOneClass(code);
    if (!OfferedClassRepository
            .isOfferedClass(offered)) {
        throw new Error(`enrollStudentInClass did not find OfferedClass for ${util.inspect(code)}`);
    }
    let student = await getStudentRepository()
                        .findOneStudent(studentid);
    if (!StudentRepository.isStudent(student)) {
        throw new Error(`enrollStudentInClass did not find Student for ${util.inspect(studentid)}`);
    }
    
    if (!student.classes) student.classes = [];
    student.classes.push(offered);
    await getStudentRepository().manager.save(student);
}

The first section of this verifies that we've been given good identifiers for a known student, and a known class.

Then adding the Student to the OfferedClass is easy. Simply add the OfferedClass instance to the classes array on the Student object, then save it back to the database. Behind the scenes TypeORM takes care of everything.

Another desired operation is to enroll a student in multiple classes at once. Rather than enrolling one at a time, it might be more efficient to receive an array of class codes and ensure the student is enrolled in each. Further, if the student is currently enrolled in a class not in that array, we should disenroll the student from that class.

In the OfferedClassRepository class add this function:

async updateStudentEnrolledClasses(
            studentid: any, codes: string[]) {
    let student = await getStudentRepository()
                        .findOneStudent(studentid);
    if (!StudentRepository.isStudent(student)) {
        throw new Error(`enrollStudentInClass did not find Student for ${util.inspect(studentid)}`);
    }
    let newclasses = [];
    for (let sclazz of student.classes) {
        for (let code of codes) {
            if (sclazz.code === code) {
                newclasses.push(sclazz);
            }
        }
    }
    for (let code of codes) {
        let found = false;
        for (let nclazz of newclasses) {
            if (nclazz.code === code) {
                found = true;
            }
        }
        if (!found) {
            newclasses.push(await this.findOneClass(code));
        }
    }
    student.classes = newclasses;
    await getStudentRepository().save(student);
}

The approach followed is to first retrieve the Student instance, then manipulate the classes the student is enrolled in, then finally to save the Student.

We create an empty array that will hold the new set of classes the Student is enrolled in. Then we use a couple for loops to set this array correctly. In the first we are copying over OfferedClass instances that are still listed in the array of class codes. In the second we pass through the array of available classes, and for any class not in the array we push its OfferedClass instance into the array.

As a result newclasses has the OfferedClass instances corresponding to the codes we were given. We simply save the Student instance.

Unit testing the Student and OfferedClass entities

[return to top]

In the test directory we already set up the bones of a Node.js project.

In that directory, make a file named index.js. This will contain a Mocha/Chai test suite. Because we want to test the JavaScript interface to the Registrar module, the test suite is written in JavaScript.


const util = require('util');
const path = require('path');
const assert = require('chai').assert;
const { 
    connect, 
    connected,
    Student,
    getStudentRepository,
    StudentRepository,
    getOfferedClassRepository,
    OfferedClassRepository
} = require('../dist/index');

describe('Initialize Registrar', function() {
    before(async function() {
        try {
            await connect("registrardb.sqlite");
        } catch (e) {
            console.error(`Initialize Registrar failed with `, e);
            throw e;
        }
    });

    it('should successfully initialize the Registrar', async function() {
        assert.isTrue(connected());
    });
});

This sets up the required modules and implements an initial test case.

Because the test directory is a subdirectory of the registrar module, we can load the module using a relative path reference like here. The generated source code is in the dist directory, and therefore we're loading modules from there.

The before function here is used to initialize the RegistrarDB connection. There isn't much to test because all we're doing is instantiating the database. The primary purpose for this test is, therefore, initializing the database, but it does do a little bit of verification that the database was successfully configured.

The test can be run this way:

$ npm test

> registrar@1.0.0 test /Volumes/Extra/ebooks/typescript-nodejs/examples/registrar
> cd test && npm run test


> registrar-test@1.0.0 pretest /Volumes/Extra/ebooks/typescript-nodejs/examples/registrar/test
> cd .. && npm run build


> registrar@1.0.0 build /Volumes/Extra/ebooks/typescript-nodejs/examples/registrar
> tsc


> registrar-test@1.0.0 test /Volumes/Extra/ebooks/typescript-nodejs/examples/registrar/test
> mocha ./index

  Initialize Registrar
    ✓ should successfully initialize the Registrar

  1 passing (13ms)

Testing the Student object

[return to top]

Let's flesh out the test suite a little bit. We don't have room in this book to fully test everything, so let's just show a couple test cases.

describe('Add students to registry', function() {
    let stud1 = {
        name: "John Brown", 
        entered: 1997, grade: 4,
        gender: "male"
    };
    let stud2 = {
        name: "John Brown", 
        entered: "trump1", grade: "senior",
        gender: "male"
    };
    let studentid1;
    let studentid2;

    it('should add a student to the registry', async function() {
        studentid1 = await getStudentRepository().createAndSave(stud1);
        let student = await getStudentRepository().findOneStudent(studentid1);
        assert.exists(student);
        assert.isObject(student);
        assert.isString(student.name);
        assert.equal(student.name, stud1.name);
        assert.isNumber(student.entered);
        assert.equal(student.entered, stud1.entered);
        assert.isNumber(student.grade);
        assert.equal(student.grade, stud1.grade);
        assert.isString(student.gender);
        assert.equal(student.gender, stud1.gender);
    });

    it('should fail to add a student with bad data', async function() {
        let sawError = false;
        try {
            await getStudentRepository().createAndSave(stud2);
        } catch (err) {
            sawError = true;
        }
        assert.isTrue(sawError);
    });

});

The two objects, stud1 and stud2, are meant for positive tests (expect success) and negative tests (expect failure). In the first, we call addStudent, then retrieve the Student object, and then check its values against the object.

In the Chai assertions is a very useful method, deepEqual, that would have been preferable here. But, the object returned from getStudent(studentid1) is a TypeScript object. I found from inspecting the object it has fields named _name etc, which you'll recall are the private fields that hold the actual data. The getter methods are not recognized as field names and therefore we were unable to do: assert.deepEqual(student, stud1), because the field names do not match up.

Therefore we end up using individual assert.isNumber and assert.isString checks. Or, we can simply use the isStudent type guard to check that the returned object is as expected.

In the negative test case (should fail to add) we're passing in stud2. This object has strings instead of the numerical fields, and those strings are not convertible to numbers. The createStudent function detects that problem and throws an error. We catch that error, and set the sawError flag to true, and the assertion will succeed. If the error is not thrown, the flag remains false and the assertion will fail.

$ npm test

> registrar@1.0.0 test /Volumes/Extra/ebooks/typescript-nodejs/examples/registrar
> cd test && npm run test

> registrar-test@1.0.0 pretest /Volumes/Extra/ebooks/typescript-nodejs/examples/registrar/test
> cd .. && npm run build

> registrar@1.0.0 build /Volumes/Extra/ebooks/typescript-nodejs/examples/registrar
> tsc

> registrar-test@1.0.0 test /Volumes/Extra/ebooks/typescript-nodejs/examples/registrar/test
> mocha ./index

  Initialize Registrar
    ✓ should successfully initialize the Registrar

  Add students to empty registrar
    ✓ should add a student to the registrar
    ✓ should fail to add a student with bad data

  3 passing (46ms)

After some more work the test results might look like:


> registry-test@1.0.0 test /Volumes/Extra/ebooks/typescript-nodejs-quick-start/registrar/test
> rm -f registrardb.sqlite && mocha ./index

  Initialize Registrar
    ✓ should successfully initialize the Registrar

  Add students to registry
    ✓ should add a student to the registry
    ✓ should fail to add a student with bad data

  Update student in registry
    ✓ should update student (44ms)
    ✓ should fail to update student with bad data

  Delete student from registry
    ✓ should not fail to delete student using bad ID
    ✓ should delete student using good ID


  7 passing (861ms)

Testing the OfferedClass entity

[return to top]

Having written a few tests of the Student entity we need to validate the OfferedClass entity. Unlike the Student entity we need to initialize a list of OfferedClass objects.

The first step is to create a data file in YAML format. Create a file named test/students.yaml:

classes:
  - code: BW101
    name: Introduction to Basket Weaving
    hours: 3
  - code: BW102
    name: Underwater Basket Weaving
    hours: 3
  - code: BW103
    name: Basket Weaving while Sky Diving
    hours: 3
  - code: BW201
    name: Basket Weaving Fundamentals
    hours: 3
  - code: BW202
    name: Historical Basket Weaving
    hours: 3
  - code: BW203
    name: Development of Modern Basket Weaving
    hours: 3
  - code: BW301
    name: Topics on Contemporary Basket Weaving
    hours: 3
  - code: BW302
    name: Basket Weaving Theory
    hours: 3
  - code: BW303
    name: Basket Weaving and Graph Theory
    hours: 3
  - code: BW401
    name: Advanced Basket Weaving
    hours: 3
  - code: BW402
    name: Basket Weaving Research Practicum
    hours: 3

This data file contains a class list from a hypothetical university interested in basket weaving.

Then add this test group:

describe('Initialize Offered Classes in registry', function() {
    before(async function() {
        await getOfferedClassRepository()
            .updateClasses(path.join(__dirname, 'classes.yaml'));
    });

    it('should have offered classes', async function() {
        let classes = await getOfferedClassRepository()
                            .allClasses();
        assert.exists(classes);
        assert.isArray(classes);
        for (let offered of classes) {
            assert.isTrue(OfferedClassRepository
                        .isOfferedClass(offered));
        }
    });
});

The before function inserts whatever is in the YAML file into the database. That initializes our test data. And we can finally live that dream from college of seeing Underwater Basket Weaving in the course catalog.

In the test case we read in all the classes, and verify that everything is indeed an OfferedClass.

> rm -f registrardb.sqlite && mocha ./index

  Initialize Registrar
    ✓ should successfully initialize the Registrar

  Add students to registry
    ✓ should add a student to the registry
    ✓ should fail to add a student with bad data

  Update student in registry
    ✓ should update student
    ✓ should fail to update student with bad data

  Delete student from registry
    ✓ should not fail to delete student using bad ID
    ✓ should delete student using good ID

  Initialize Offered Classes in registry
    ✓ should have offered classes


  8 passing (369ms)

Cool, we've verified that the OfferedClass instances exist and have the correct type. Now we need to test adding students to classes and more.

let stud1 = {
    name: "Mary Brown", 
    entered: 2010, grade: 2,
    gender: "female"
};
let studentid1;

it('should add student to a class', async function() {
    studentid1 = await getStudentRepository()
                        .createAndSave(stud1);
    await getOfferedClassRepository()
            .enrollStudentInClass(studentid1, "BW102");
    let student = await getStudentRepository()
            .findOneStudent(studentid1);
    assert.isTrue(StudentRepository
                .isStudent(student));
    assert.isArray(student.classes);
    let foundbw102 = false;
    for (let offered of student.classes) {
        assert.isTrue(OfferedClassRepository
                    .isOfferedClass(offered));
        if (offered.code === "BW102") foundbw102 = true;
    }
    assert.isTrue(foundbw102);
});

As before we have a static object from which to create a Student. We then enroll the student in a class. And then we retrieve the student again, and check its classes array to ensure that she is taking BW102.

After some additional test case development we have:

> rm -f registrardb.sqlite && mocha ./index

  Initialize Registrar
    ✓ should successfully initialize the Registrar

  Add students to registry
    ✓ should add a student to the registry
    ✓ should fail to add a student with bad data

  Update student in registry
    ✓ should update student
    ✓ should fail to update student with bad data

  Delete student from registry
    ✓ should not fail to delete student using bad ID
    ✓ should delete student using good ID

  Initialize Offered Classes in registry
    ✓ should have offered classes
    ✓ should add student to a class
    ✓ should add student to three classes
    ✓ should show students registered in class

  11 passing (372ms)

About the Author(s)

(davidherron.com) David Herron : David Herron is a writer and software engineer focusing on the wise use of technology. He is especially interested in clean energy technologies like solar power, wind power, and electric cars. David worked for nearly 30 years in Silicon Valley on software ranging from electronic mail systems, to video streaming, to the Java programming language, and has published several books on Node.js programming and electric vehicles.

Books by David Herron

(Sponsored)