Thursday, August 3, 2017

ORM and JSON in JavaScript and TypeScript

Mapping JSON to entity objects and objects to JSON is trivial, if the objects only contain data and allow direct access to the properties. There is a bit more work if the class needs to have helper methods for things like validation, and if we want to wrap the data members with getters and setters.

The goal is to meet these requirements during coding:
  • Entity classes may include validation
  • Data members have accessor (getters and setters)
  • Entity classes may have other methods
  • Entity class type is important (this encapsulates the previous three points)
  • Missing data in the JSON source should be logged.
  • Extra data in the JSON source should be logged.
  • Support DRY - Don't Repeat Yourself
Full disclosure here: I know that there are two big problems with what I am about to describe. If you have been trying to handle ORM conversion already, you may have hit them. The problems are not insurmountable, but we'll get to that later.


Converting to Entities

Simply using JSON.parse() to turn a JSON string into an object will only work if nothing ever needs to know about the type of the object, and if the objects only wrap data. JSON data is not typed, and for security only includes data, never includes methods. And, for security reasons always use JSON.parse to create an object from the string.

If entities need to be typed and encapsulate other methods, then the only solution is to convert a JSON-parsed object into an object of the entity type that we need.

So an entity framework is not devoid of inheritance: does the entity class being populated inherit from another entity class? Which classes are responsible for initializing which members? How much does the initialization from JSON data need to be distributed between subclasses and super-classes?

Fortunately, it turns out that all of this can be pushed into the constructor of one super-class at the top of an entity framework. Three capabilities of reflection in the JavaScript environment that have been around since ES5.1, getPrototypeOf(), getOwnPropertyNames(), and getOwnPropertyDescriptor() allow code to walk the prototype tree and identify all of the setters. The setter functions define which properties should be initialized from the JSON data.

This entity class is built in TypeScript. Simply remove the ": ?" typing and "<?>" type-casting to get the JavaScript (ES6) version. Note that the constructor is passed a possible source object to initialize from, and this is passed to the super-class constructor. The example project is available on GitHub: https://github.com/jmussman/jm-typescript-orm-example.

import Entity from 'data/Entity'

class User extends Entity {

    private __id: string
    private _firstName: string
    private _lastName: string
    private _password: string

    public constructor(source: any) {

        super(source)
    }

    public get _id(): string {

        return this.__id
    }

    public set _id(value: string) {

        this.__id = value
    }

    public get firstName(): string {

        return this._firstName
    }

    public set firstName(value: string) {

        this._firstName = value
    }

    public get lastName(): string {

        return this._lastName
    }

    public set lastName(value: string) {

        this._lastName = value
    }

    public get password(): string {

        return this._password
    }

    public set password(value: string) {

        this._password = value
    }
}

export default User

So if a source object is provided the initialization work is done in the super-class constructor, and the constructor leverages a listAllProperties method to find the properties to initialize:

class Entity {

    // constructor
    // The constructor will initialize the object from the properties defined using getters and
    // setters methods.
    constructor(source: any) {

        if (source) {

            // Get the name of this class for logging

            let match: RegExp = /^(class|function)?\s+(\w+)(\r|\n|.)*$/
            let className: string = this.constructor.toString().replace(match, '$2')

            // Match every expected property and initialize the value.

            let properties: string[] = this.listAllProperties()

            properties.forEach( property => {

                if (typeof source[property] !== 'undefined') {

                    let self: any = this

                    self[property] = source[property]
                
                } else {

                    console.log(`class ${className}: Attempt to initialize entity from missing source property '${property}'`)
                }
            })

            // Find and log any unexpected properties.

            for (let property in this) {

                if (!properties.indexOf(property)) {

                    console.log(`class ${className}: Source for entity has unexpected property '${property}'`)
                }
            }
        }
    }

    // listAllProperties
    // This method will list all the properties defined in the prototype chain that are setters, up to
    // but not including Object.prototype. It uses reflection (for... in, and getOwnPropertyDescriptor)
    // to spin through the properties and decide if a property is a setter.
    private listAllProperties(): string[] {

        let result: string[] = []
    
        for (let objectToInspect: Object = Object.getPrototypeOf(this);
            objectToInspect !== null && objectToInspect != Object.prototype;
            objectToInspect = Object.getPrototypeOf(objectToInspect)) {

            Object.getOwnPropertyNames(objectToInspect).forEach( property => {

                let propertyDescriptor: PropertyDescriptor =
                    Object.getOwnPropertyDescriptor(objectToInspect, property)

                if (propertyDescriptor.get && propertyDescriptor.set) {

                    result.push(property)
                }
            })
        }
 
        return result
    }
}

The method to find the properties depends on understanding how the prototype inheritance chain works in JavaScript, and you can read this post to learn more about object-oriented programming in ES5.

The method starts with the prototype of the current object and walks down the chain, ignoring the last prototype which always has to be Object.prototype. Using getOwnPropertyNames() to find the properties in the current prototype object, it then keeps only the properties that have a getter and a setter method defined.

The constructor is simple; walk through the properties of the source object and assign the values to the corresponding properties of the object being initialized. If any source properties do not exist in the destination, they are logged. If any destination properties do not exist in the source, they are logged.

The really, really cool part is that if any of the setters implement constraints, they automatically take effect, because the setters are used to initialize the destination properties!

JSON from Entities

JSON.stringify() only serializes data members; getters and other methods are ignored. There are two reasons for wanting to work around this: the names of the data members in the class do not match what the other side of the communication needs, and the getters should be used to make sure their code is invoked and constraints are met while reading the data.

Fortunately, JSON.stringify() stringifies whatever object is passed back from the objects toJSON(), if it is defined. Rather than jump through hoops trying to get every sub-class to add its own properties to an object to return, the simplest form is to leverage the listAllProperties() method in the super-class and just have it build the required object from the getters:


    public toJSON(): any {

        let properties: string[] = this.listAllProperties()
        let dataObject: any = {}

        properties.forEach( property => dataObject[property] = (<any>this)[property])

        return dataObject
    }
}

Now there is no need to worry about overriding oJSON() in any of the sub-classes.

Wrap-Up

This solves every requirement: direct client access to data members is avoided, getters and setters are consistently used as the place to implement constraints, JSON data is easily translated in and out of entity classes that may have other methods, and best of all, it is all accomplished in one place in the super-class!

Get the example project on GitHub: https://github.com/jmussman/jm-typescript-orm-example.

Oh, wait. What about those problems that I mentioned earlier? Well, if you made it this far, the problems are:
  1. Unless there is constraint checking going on inside the setters, nothing is making sure that the right type of data ends up in each property.  If you are lucky, everything works. If your not, then the TypeScript compiler's type checking is not going to help at all, because all of this is happening at run-time.
  2. Wait just a minute... what happens if the value of a property is supposed to be a reference to another object? Or an array of objects?

Both of these problems require keeping to meta-data about the properties around. And, that will be the subject of a follow-on to this post!

No comments:

Post a Comment