Skip to main content

Devii Backbone.js Library

Description

PXDB.js provides a library to interface with Devii. This includes a connection engine class to handle authentication and route and schema retrieval, facilities for automatically parsing GraphQL schemas and generating GraphQL query templates, and automatic generation of Backbone model and collection classes to work with the schema.

This library uses GraphQL schema introspect and Javascript metaclass code generation to create Backbone model and collection classes for GraphQL schema objects, with models corresponding to individual rows, and collections corresponding to tables or views. These automatically generated classes come with built-in methods for query filtering, ordering, and paging, as well as validations for mutations.

For more information on Backbone.js: backbonejs.org

Installation

⬇️ Download pxdb.js here: pxdb_backbone.zip

Place the following <script>s near the end of your pages, right before the closing </body> tag, to enable them. jQuery must come first, then lodash.js, backbone.js, graphql.js, and pxdb.js, and then the rest of the JavaScript plugins.

    <script type="text/javascript" src="libs/jquery-3.3.1.js"></script>
<script type="text/javascript" src="libs/lodash.min.js"></script>
<script type="text/javascript" src="libs/backbone.js"></script>
<script type="text/javascript" src="libs/graphql.js"></script>
<script type="text/javascript" src="pxdb.js"></script>

Additionally to handle file handling with Azure Storage add the following <script>

<script type="text/javascript" src="libs/azure-storage.blob.min.js" charset="utf-8"></script>

Using pxdb.engine

For our examples, you can use the index.html from the pxdb_backbone.zip. It is a test file that can be used to run the functions, queries, and mutations below.

Interaction is very simple, just create an engine like so:

URL = "https://api.devii.io";
myengine = new pxdb.engine(URL);

And then log in a role using the login() method:

myengine.login(LOGIN, PASSWORD, TENANTID);

The engine class will then log in, introspect the tenant schema, and generate the GraphQL query templates and Backbone classes to use them. The Backbone models automatically have validator methods provided to ensure correct queries and mutations, and the model and collection classes have been enhanced with facilities to better exercise the power of Devii GraphQL queries, including field selection, filtering expressions, server-side ordering, and paging.

Queries

PXDB.js can query the backing database based on several criteria, namely, a filter expression (like a "where" clause), a set of attributes to fetch, or "selection", and ordering and paging parameters. Queries can be performed on individual models, or on collections, though the latter is preferred.

In these examples, we will use a table called "world_borders", which contains UN country boundaries and data. It has the following schema definition:

type world_borders {
gid: ID!
fips: String
iso2: String
iso3: String
un: Int
name: String
area: Int
pop2005: Int
region: Int
subregion: Int
lon: Float
lat: Float
geom: Geometry
}

Model queries

It's possible to query using a single model, by creating the model, assigning the ID attribute to the ID you want, and then calling the model's .fetch() method.

Model-based ID lookup.
country = new myengine.classes.world_borders();
country.id = 1;
country.fetch();

In our example table, this is the nation of Antigua and Barbuda. The model now has all of the attributes available for this table.

Attribute selection

If we want to limit which attributes are fetched, we have the .selection() method, which allows us to only fetch the listed attributes. Consider the above, but before calling .fetch(), we add this call:

Setting selection on a model.
country.selection(["gid", "name"]);

Now, when .fetch() returns, the model will only have the "gid" and "name" attributes. NB: The defined ID column for a given table is required, as this is used to identify individual models.

The argument to .selection() is always an array; the elements of that array may be strings, in which case they must name scalar attributes of the model type, or objects, in which case they must follow the rules for selecting on object-type fields (see below).

By default, models are configured to select only the attributes that correspond to columns in the source table, and not to fetch from related tables. This doesn't matter for queries when the model exists in a collection, as the collection's selection will be consulted there; it does, however, make a difference for mutations.

Limitations of model-based query

While fetching individual models does work, this is not the best way to perform queries; it only works if a row with the given ID actually exists in the table. If it does not, then the resulting model will have no attributes. For more powerful queries, the collection class should be used.

Collection queries

Collections offer a much more powerful means of query: in addition to .selection(), they also offer filter expressions through .expression(), server-side sorting through .ordering(), and paging support through .paging().

First, initialize the collection:

countries = new myengine.classes.world_borders_collection();

Attribute selection

Selection works precisely as it does on individual models:

Setting selection on a collection
countries.selection(["gid", "name", "region", "subregion"]);

Subsequent calls to .fetch() on this collection will only fetch those attributes.

Note that the selection defined on a collection does not, by default, apply to the individual models inside it: each model has its own .selection() method, which can be set by drilling down to the model:

countries.get(1).selection(["gid", "name"]);
countries.get(1).fetch();

This will update the model with the specified attributes. It will not remove any already set attributes, however; these can be manually unset, if desired. This feature is useful for fetching a minimal set of attributes for an entire table, or a large subsection thereof, and then "drilling down" to fetch more information about a particular model when necessary.

Selecting Object-Typed Fields

Some of the attributes declared on a GraphQL type are not scalar types, but composite object types with their own attribute fields. An object type can have object fields for one of two reasons: relationships, or "special" column types.

Selecting these works slightly differently than for scalar fields, as it requires a "sub-selection". This is represented in the array argument to .selection() as an object with one key, that being the attribute name, and its value being an array of attributes of that object type to fetch.

For instance, if a schema type represents a table with relationships to other tables, those relationships can be used to select related data from those other tables, akin to a SQL left join.

Consider this schema fragment:

type people {
personid: ID!
name: String!
placeid: Int
place: places
things_collection: [things]
}

type places {
placeid: ID!
name: String!
x: Float
y: Float
people_collection: [people]
}

type things {
thingid: ID!
name: String!
value: Float!
people_collection: [people]
}

In this schema, 'people' has a many-to-one relationship to 'places', and a many-to-many relationship to 'things'. Using object-type attribute selections, we can retrieve the associated place and things for all of our people, like so (selection is pretty-printed for clarity):

var mypeeps = new myengine.classes.people_collection();
mypeeps.selection([
"personid",
"name",
"placeid",
{
place: ["placeid", "name", "x", "y"],
},
{
things_collection: ["thingid", "name", "value"],
},
]);
mypeeps.fetch();

Examining the results:

mypeeps.each(m => console.log(m.id, m.get('name'), m.get('place')));
>> 1 Alice {name: "At home", placeid: "1", x: 0, y: 0}
>> 2 Bob {name: "Nowhere in particular", placeid: "5", x: 2, y: 3}
>> 3 Carla {name: "T H E V O I D", placeid: "3", x: -1, y: -1}
>> 4 Dan {name: "At home", placeid: "1", x: 0, y: 0}
>> 5 Eve {name: "R'lyeh", placeid: "6", x: -126.717, y: -47.15}

mypeeps.each(m => _.each(m.get('things_collection'), t => console.log(m.id, t)));
>> 1 {name: "pen with no ink", thingid: "1", value: 1}
>> 1 {name: "fish wearing glasses and a top hat", thingid: "2", value: 3}
>> 1 {name: "wagon fulla pancakes", thingid: "7", value: 22.95}
>> 1 {name: "80's movie-style montage", thingid: "8", value: 10000}
>> 2 {name: "ball bearing", thingid: "3", value: 1.8}
>> 2 {name: "sense of ennui", thingid: "5", value: -1000}
>> 2 {name: "fish wearing glasses and a top hat", thingid: "2", value: 3}
>> 3 {name: "80's movie-style montage", thingid: "8", value: 10000}
>> 3 {name: "pen with no ink", thingid: "1", value: 1}
>> 3 {name: "deep existential dread", thingid: "6", value: -1000000}
>> 3 {name: "sense of ennui", thingid: "5", value: -1000}
>> 3 {name: "rocket pants", thingid: "4", value: 12}
>> 4 {name: "pen with no ink", thingid: "1", value: 1}
>> 4 {name: "sense of ennui", thingid: "5", value: -1000}
>> 4 {name: "80's movie-style montage", thingid: "8", value: 10000}
>> 4 {name: "wagon fulla pancakes", thingid: "7", value: 22.95}
>> 5 {name: "THE CROMCH", thingid: "9", value: 9001}

This fetching of data from related tables can be nested, theoretically as deeply as desired; in practice, it's a very bad idea to nest too deeply, as it leads to poor performance on fetches. Each sub-selection that refers to a table (as opposed to a special type) involves a join on the server; excessive joins can lead to very poor query performance. For this reason, it is very important to set selections to fetch only what data is needed for a given query.

Besides related tables, there are also some "special" types that represent column data which is handled specially by Devii. At present, there are two such types, FileObject and Geometry, which represent files linked to database records by Devii's Multimedia subsystem, and GIS geometry data handled by the Geospatial subsystem.

For example, consider the 'geom' field on world_borders. This has a type of Geometry, which looks like this:

type Geometry {
srid: Int
wkt: String
centroid: String
envelope: String
}

To select any of this data with a query on world_borders, the selection would look something like this:

countries.selection([
"gid",
"name",
"region",
"subregion",
{
geom: ["srid", "wkt", "centroid", "envelope"],
},
]);

In this case, we are choosing to fetch all of the data about the geometry; we can be selective, of course:

countries.selection([
"gid",
"name",
"region",
"subregion",
{
geom: ["centroid", "envelope"],
},
]);

In the above case, we chose to only retrieve the centroid (center point) and envelope (bounding box) fields.

Selection inheritance

It can be desirable to have the collection's selection automatically propagate to the models; this can be useful, for instance, when the selection is carefully set to return only the desired attributes, and models need to respect this selection when updating. In this case, the "inheritSelection" flag can be set to true on the collection. This will cause all of the collection's models to automatically use the selection of their collection.

Model selections can still be overridden manually, by calling selection() on the model, with inheritSelection set to true. However, the selection will be reset on fetch, save, or other "refreshing" of the model.

Filter expressions

The filter expression syntax is similar to SQL where-clause syntax, with some differences (full documentation TBD). Here are some examples, using the same "world_borders" table.

Obviously, one easy thing to do is just fetch a single model, using a primary-key equality expression:

Collection-based ID lookup.
countries.expression("gid = 1");
countries.fetch();

This will fetch one model and add it to the collection. Again, we now have the full data for Antigua and Barbuda.

We can do more with expressions, though:

Comparison operators
countries.expression("gid <= 10");
countries.fetch();
Filter by arbitrary attributes
countries.expression("region = 19");
countries.fetch();
SQL-style string wildcards
countries.expression("name ilike 'a%'");
countries.fetch();
Boolean operators like AND/OR/NOT
countries.expression("region = 19 and subregion = 5");
countries.fetch();
Parenthesized expressions
countries.expression("region = 142 AND (NOT area = 0)");
countries.fetch();
Function calls (some)
countries.expression("region = 142 AND st_y(st_centroid(geom)) > 0");
countries.fetch();

Server-side sorting

The .ordering() method lets you tell the server to sort results by specific columns, in ascending (default) or descending order.

Order by name
countries.ordering(["name"]);
countries.fetch();
Order by region, then subregion, then name
countries.ordering(["region", "subregion", "name"]);
countries.fetch();
Order by region, then subregion, then pop2005 descending
countries.ordering(["region", "subregion", "pop2005 desc"]);
countries.fetch();

Paging

"Paging" support allows you to fetch only a limited number of rows at a time; this is useful when a table is likely to be large.

Set up paging with the .paging() method:

Setting page size
countries.paging(20); //20 rows at a time
countries.fetch();

When paging is set up, the collection is set to fetch from the first page. Set the page number with .setPage():

Changing pages
countries.setPage(2);
countries.fetch();

Page sizes can be changed, but this resets the page number to 1. Note also that by default, going to the next page will empty the collection when .fetch() is called. If this is not what you want, use the standard Backbone {remove: false} option to prevent this:

Adding paged data without removing
countries.setPage(3);
countries.fetch({ remove: false });

Finally, paging can be turned off by calling .clearPaging():

Turning off paging
countries.clearPaging();
...

Mutation (create, update, delete)

All "mutations" are accomplished using the standard Backbone protocol: create new models by creating a new model class, and setting its attributes:

Creating a new row
newobj = new myengine.classes.objects();
newobj.set(...);

...and then persist the new model with .save():

Persisting a model
newobj.save();

This will send a GraphQL create mutation to the server, and retrieve the ID of the new model, which will then be set.

Update a model by setting its attributes to new values, and then calling .save():

Update
obj.set(key, newval);  //Single attribute updated.
obj.set({key1: newval, key2:newval,...}); //Multiple attributes updated.
obj.save();

And deletion works by calling .destroy():

Delete with .destroy()
obj.destroy();

All of this is just as it is for standard Backbone. Where this library differs is in predefining a .validate() method for you. This method automatically checks for invalid mutations. It disallows setting attributes that are not defind for the GraphQL schema type, setting attributes to the wrong underlying scalar type, and setting attributes that are only defined for the query type, but not the input type.

The protocol is as for standard Backbone: calls to .save() (and, if requested, .set()) will not proceed if the validation method returns an error. Make sure to check the model for a validationError property if .save() does not proceed.

Token handling

Devii uses JSON Web Tokens (JWTs) as authentication tokens for login. These tokens have an expiration time, and there are two of them issued on each login. One, the access token, has a shorter lifetime; the other, the refresh token, much longer. The refresh token can only be used to request a new access token, while the access token is used for all other requests. The exact lifetimes of each token are configured by the Devii server; our installation uses 24 hours for access tokens, and 7 days for refresh tokens.

Most of the time, you will not need to worry about this: PXDB.js handles the storing and sending of tokens for you. However, if your access token expires, calls to .fetch(), .save(), or .destroy() will fail with error code 401. Be prepared to handle this error by calling the .token_refresh() method on the engine:

Refreshing access token
myengine.token_refresh();

This will update the stored token.

In the case of the refresh token expiring, a new login is required. This is unlikely to be necessary within a single session.