TypeScript and Gremlin: Part 2

· 1139 words · 6 minutes read

This article demonstrates a pattern for accessing graph databases in TypeScript. Make sure you read Part 1 - without it, these next steps won’t make sense. This article assumes an intermediate knowledge of TypeScript and how generics and interfaces work within the language. This article also mentions the Data Mapper and Factory patterns.

Graph Database Interface

Most books about program design will spend some time talking about the benefits of separation between application logic and storage logic. In the case of our application, we do not want to be bound to a single graph database provider (CosmosDB, Neptune, etc). To accomplish this we will provide our application with a graph database interface.

An interface tells a caller the shape of the value that’s being passed to it. It describes what methods exist on that value and how to treat it overall (see Interfaces ).

Our interface describes a graph database provider. We will also declare interfaces to represent the Vertex data type along with its sub-types.

export interface GraphAdapter {
    addVertex(type: string, input: any): Promise<Result<Vertex>>
}


export interface Vertex {
    id: string
    label: string
    properties?: VertexProperty[]
}
   
export interface VertexProperty {
    id: string
    label: string
    value: any
    properties?: Property[]
}

export interface Property {
    key: string
    value: any
}

Now, any function that takes or returns a value of type GraphAdapter can accept or return a GremlinAdapter as well - without having to explicitly tell the application which graph database provider we’ve decided to use. As long as the application uses and refers to the GraphAdapter interface when interacting with the graph database we’ll have gained the ability to switch our graph database provider without having to modify a large amount of code.

export default class Asset {
    static readonly label = 'asset'
    id: string
    name: string

    constructor(id: string, properties: any) {

        this.id = id
        this.name = properties.name || "" 
    }

    public static factory(id: string, properties: any): Asset {
        return new Asset(id, properties)
    }
}

An easy way to create separation between data types and a storage layer is to follow the Data Mapper Pattern. We’ll create a basic implementation of this pattern by creating a class called AssetStorage and ensuring that any code related to how an Asset interacts with our graph database is kept here and not in the Asset class itself.

AssetStorage should contain three things. First, we need to hold an instance of our graph database adapter. We could do something like this -

class AssetStorage {
    public db: GremlinAdapter
}

AssetStorage can now access GremlinAdapters methods internally, so we can do things like adding a vertex or an edge etc. However, we don’t want AssetStorage to have any knowledge of mechanics behind the graph database data storage (like what graph provider), only that it’s being stored in a graph database. We also want to keep the ability to quickly switch out graph database providers if needed.

Enter our GraphAdapter class.

export default class AssetStorage {
    public db: GraphAdapter

    public constructor(db: GraphAdapter) {
        if (db === null) throw new Error('you must provide a graph database adapter')
        this.db = db
    }
}
public constructor(db: GraphAdapter) {
    if (db === null) throw new Error('you must provide a graph database adapter')
    this.db = db
}

Create(name: string): Promise<Asset> {
    return new Promise((resolve, reject) => {
        this.db.addVertex(Asset.label, { name })
            .then((result) => {
                resolve(new Asset(result.id, properties))
            })
            .catch(e => reject(e));
    })
}

}


Note how the function’s parameter isn’t an `Asset` class. Instead, we’re requiring that the caller give us all the information needed to create a new `Asset`. As long as our insertion of the record is successful, we return a concrete type - the new `Asset`.

<br>
Fantastic! We’ve successfully implemented a data mapper class and integrated it with our graph database interface and adapters. Now, with a little setup, we can start working with our Asset.

let g = new GremlinAdapter() let assetStorage = new AssetStorage(g)

g.Create(“Test Asset”) // Promise


<br>
# Storage

You can get a lot of mileage from this pattern by stopping here. You’ve created a solid barrier between application logic and storage logic - and then went one better by separating your storage logic from the means by which the data is stored.

There is a downside however. Implementing the `*Storage` class pattern with other record types means you must rewrite the `Create` function for each type. This isn’t the end of the world, and in other cases it might be something you have no choice but to live with. Because we’re working with graph databases - we can do something different.

In a graph database a Vertex record is always the same thing. It consists of an ID, label, and a set of properties. No matter what kind of data the vertex is holding, it must always conform to that structure. With this in mind, we can cut down the amount of boilerplate that would have to be written for basic CRUD functions for each record type.

Let’s create a Storage class - of which every record Storage class (e.g AssetStorage) will inherit from. We’ll also remove the constructor from `AssetStorage` as it now inherits from Storage.

export default class Storage { public db: GraphAdapter

public constructor(db: GraphAdapter) {
    if (db === null) throw new Error('you must provide a graph database adapter')
    this.db = db
}

}


We want to pull the bulk of the `Create` function from `AssetStorage` into the parent `Storage` class - but we also want to maintain the behavior of passing parameters in and getting a concrete type out.

Enter [Generics](https://www.typescriptlang.org/docs/handbook/generics.html) and [Factories](https://en.wikipedia.org/wiki/Factory_method_pattern).

<br>

# Generic Create & Record Factory

Before we go on, let’s revisit the `factory` method on our Asset class - basically a wrapper over the `constructor` function for an Asset.

public static factory(id: string, properties: any): Asset { return new Asset(id, properties) }


<br>
In order to maintain the behavior of accepting parameters and returning a concrete type, the `Create` function we’re adding to the Storage class needs to include an additional parameter - a factory function. Now, once a vertex is created, we use the factory function to return a concrete type specified by the caller.

create(factory: (id: string, properties: any) => T, label: string, properties: any): Promise { return new Promise((resolve, reject) => { this.db.addVertex(label, properties) .then((result) => { resolve(factory(result.id, result.properties)) }) }) }


<br>
The power of this pattern becomes apparent when we modify the `AssetStorage` class to reflect the new functionality.

export default class AssetStorage extends Storage { public Create(name: string): Promise<Result> { return super.create(Asset.factory, Asset.label, { name }) } }


<br>

Abstraction should offer flexibility. I believe the patterns shown in this two part article set represent a powerful, flexible way of interacting with graph databases in TypeScript. I encourage you to experiment and expound on my findings - and reach out if you have any questions or suggestions.
Image of Author John Darrington

Author:  John Darrington

John is a software engineer who spends his time working on production-ready code for clients with interesting problems. He loves coding, but also enjoys writing and building keyboards in his spare time.

See something which isn't right? You can contribute to this page on GitHub and we'll take care of it - Thanks for reading!