Using Nested Resources in JSData

January 3rd 2016 JSData

JSData is built around resources and CRUD operations on them. It is an easy to understand abstraction as long as our domain maps well to it and can be easily implemented using the following operations:

  • Create, update or delete a single instance of a resource.
  • Get all instances of the resource or a single one by its unique identifier.

Getting a subset of instances is usually a logical next step. The easiest way to implement that is by including a filtering condition in the query string, which can be used by the server to return the corrects subset of results:

// get all books - resulting URL: /books
var books = Book.findAll();

// get books published in 2015 - resulting URL: /books?year=2015
var filteredBooks = Book.findAll({ year: 2015 });

This works great but with an increasing number of filtering conditions, it becomes tempting to move conditions out of the query string:

// get all books by a specific author published in 2015

// URL: /books?authorId=42&year=2015
filteredBooks = Book.findAll({ authorId: 42, year: 2015 });

// URL: /books/42?year=2015
filteredBooks = Book.find(42, { year: 2015 });

According to the convention, the second call should retrieve a single book with ID 42, but if the server interprets it differently and returns an array of books by author with ID 42, JSData will happily accept that and cache all of them in the local store.

I think that it should throw some kind of error, but that is not the case; hence such arguably incorrect code can remain unnoticed until it suddenly bites back. Since find is expected to return a single item, JSData optimizes away multiple concurrent requests for the same item:

// this will be executed
var booksIn2015 = Book.find(42, { year: 2015 });

// if called before the above promise resolves,
// it will just return the same promise
var booksIn2014 = Book.find(42, { year: 2014 });

This behavior can be traced back to JSData source:

if (!(id in resource.pendingQueries)) {
  // ...
} else {
  // resolve immediately with the item
  return item;
}

It might make perfect sense if the call is expected to return the same book in both calls, but it introduces a hard to find bug if the syntax is misused to filter the books by author. To fix it, either findAll must be used and authorId moved back to query parameters as originally suggested, or nested resources must be used so that JSData can interpret the calls correctly:

var Book = DS.defineResource({
  name: 'book',
  endpoint: 'books',
  relations: {
    belongsTo: {
      author: {
        parent: true,
        localField: 'author',
        localKey: 'authorId',
      }
    }
  }
});

Now JSData will exclude authorId from the query string when findAll is used:

// URL: /authors/42/books?year=2015
filteredBooks = Book.findAll({ authorId: 42, year: 2015 });

Of course the generated URL is different, but also more correct in my opinion. Multiple concurrent calls will now also all be executed:

// this would be executed any way
var booksIn2015 = Book.findAll({ authorId: 42, year: 2015 });

// this will be executed even
// if the above promise has not resolved yet
var booksIn2014 = Book.findAll({ authorId: 42, year: 2014 });

Again, we can make sure about that by checking the source:

if (!(queryHash in resource.pendingQueries)) {
  // ...
} else {
  // resolve immediately with the items
  return items;
}

Matching calls will still be optimized away, but in this case a query is identified by all query parameters composed into its queryHash (returned by JSON.stringify(params)), not only the id value. This will ensure that queries with different parameters will not be collapsed into one and correct result will always be returned.

Copyright
Creative Commons License