Skip to content

Commit

Permalink
Merge pull request #41 from centro/paged-query
Browse files Browse the repository at this point in the history
Adds support for paged query arrays
  • Loading branch information
burrows committed Sep 27, 2016
2 parents e622252 + 34d93d3 commit f80109e
Show file tree
Hide file tree
Showing 2 changed files with 243 additions and 47 deletions.
121 changes: 121 additions & 0 deletions spec/model_spec.js
Expand Up @@ -913,6 +913,25 @@ describe('Model', function () {
it('decorates the returned array with a catch method', function() {
expect(typeof this.a.catch).toBe('function');
});

it('decorates the returned array with an isPaged property set to false', function() {
expect(this.a.isPaged).toBe(false);
});

it('decorates the returned array with a baseOpts property set to the given options', function() {
let a = BasicModel.buildQuery({a: 1, b: 2});
expect(a.baseOpts).toEqual({a: 1, b: 2});
});

describe('with a pageSize option', function() {
beforeEach(function() {
this.a = BasicModel.buildQuery({pageSize: 10});
});

it('decorates the returned array with an isPaged property set to true', function() {
expect(this.a.isPaged).toBe(true);
});
});
});

describe('query array', function() {
Expand Down Expand Up @@ -961,6 +980,19 @@ describe('Model', function () {
expect(QueryTest.mapper.query).toHaveBeenCalledWith({a: 1, b: 3, c: 4});
});

it('uses the newly set baseOpts', function(done) {
const a = QueryTest.buildQuery({a: 1, b: 2});
a.query({b: 3, c: 4});
expect(QueryTest.mapper.query).toHaveBeenCalledWith({a: 1, b: 3, c: 4});
this.resolve([]);
this.delay(() => {
a.baseOpts = {a: 10, b: 20};
a.query({b: 3, c: 4});
expect(QueryTest.mapper.query).toHaveBeenCalledWith({a: 10, b: 3, c: 4});
done();
});
});

it('sets the isBusy property', function() {
expect(this.a.isBusy).toBe(false);
this.a.query();
Expand Down Expand Up @@ -1064,6 +1096,19 @@ describe('Model', function () {
});
});

it('does not queue the latest call to query when the array is busy and the options are identical to the current query', function(done) {
this.a.query({foo: 1});
expect(this.a.isBusy).toBe(true);
this.a.query({foo: 1});
expect(QueryTest.mapper.query.calls.count()).toBe(1);
expect(QueryTest.mapper.query).toHaveBeenCalledWith({foo: 1});
this.resolve([]);
this.delay(() => {
expect(QueryTest.mapper.query.calls.count()).toBe(1);
done();
});
});

it('properly resolves the promise when the query is queued', function(done) {
var spy1 = jasmine.createSpy(), spy2 = jasmine.createSpy();

Expand Down Expand Up @@ -1106,6 +1151,82 @@ describe('Model', function () {
Foo.buildQuery().query();
}).toThrow(new Error("Foo._callMapper: mapper's `query` method did not return a Promise"));
});

describe('with the pageSize option', function() {
beforeEach(function() {
this.a = QueryTest.buildQuery({pageSize: 3});
});

it('forwards the pageSize option on to the mapper and a default page of 1', function() {
this.a.query();
expect(QueryTest.mapper.query).toHaveBeenCalledWith({pageSize: 3, page: 1});
});

it('forwards the page option on to the mapper', function() {
this.a.query({page: 4});
expect(QueryTest.mapper.query).toHaveBeenCalledWith({pageSize: 3, page: 4});
});

it('sets the length of the array to the value of the meta.totalCount value resolved by the mapper', function(done) {
expect(this.a.length).toBe(0);
this.a.query();
this.resolve({meta: {totalCount: 21}, results: [{id: 1}, {id: 2}, {id: 3}]});
this.delay(() => {
expect(this.a.length).toBe(21);
done();
});
});

it('splices the results into the array instead of replacing', function(done) {
this.a.query();
this.resolve({meta: {totalCount: 7}, results: [{id: 1}, {id: 2}, {id: 3}]});
this.delay(() => {
expect(this.a.length).toBe(7);
expect(this.a.map(x => x.id)).toEqual([1, 2, 3, undefined, undefined, undefined, undefined]);

this.a.query({page: 2});
this.resolve({meta: {totalCount: 7}, results: [{id: 4}, {id: 5}, {id: 6}]});
this.delay(() => {
expect(this.a.length).toBe(7);
expect(this.a.map(x => x.id)).toEqual([1, 2, 3, 4, 5, 6, undefined]);

this.a.query({page: 3});
this.resolve({meta: {totalCount: 7}, results: [{id: 7}]});
this.delay(() => {
expect(this.a.length).toBe(7);
expect(this.a.map(x => x.id)).toEqual([1, 2, 3, 4, 5, 6, 7]);

done();
});
});
});
});

it('automatically fetches pages when items are accessed through the #at method', function(done) {
expect(this.a.at(0)).toBe(undefined);
expect(QueryTest.mapper.query).toHaveBeenCalledWith({pageSize: 3, page: 1});
this.resolve({meta: {totalCount: 7}, results: [{id: 1}, {id: 2}, {id: 3}]});

this.delay(() => {
expect(this.a.length).toBe(7);
expect(this.a.at(0)).toBe(QueryTest.local(1));
expect(this.a.at(1)).toBe(QueryTest.local(2));
expect(this.a.at(2)).toBe(QueryTest.local(3));

expect(this.a.at(3)).toBe(undefined);
expect(QueryTest.mapper.query).toHaveBeenCalledWith({pageSize: 3, page: 2});
this.resolve({meta: {totalCount: 7}, results: [{id: 4}, {id: 5}, {id: 6}]});

this.delay(() => {
expect(this.a.length).toBe(7);
expect(this.a.at(3)).toBe(QueryTest.local(4));
expect(this.a.at(4)).toBe(QueryTest.local(5));
expect(this.a.at(5)).toBe(QueryTest.local(6));
done();
});
});
});
});
});

describe('#then', function() {
Expand Down
169 changes: 122 additions & 47 deletions src/model.js
Expand Up @@ -114,6 +114,89 @@ function hasManyArray(owner, desc) {
return a;
}

// Internal: Provides the implementation of the query array's `query` method.
function queryArrayQuery(queryOpts = {}) {
const opts = Object.assign({}, this.baseOpts, queryOpts);

if (this.isPaged) { opts.page = opts.page || 1; }

if (this.isBusy) {
if (util.eq(opts, this.__currentOpts__)) { return this; }

if (!this.__queued__) {
this.__promise__ = this.__promise__.then(() => {
this.query(this.__queued__);
this.__queued__ = undefined;
return this.__promise__;
});
}

this.__queued__ = opts;
}
else {
this.isBusy = true;
this.__currentOpts__ = opts;
this.__promise__ = this.__modelClass__._callMapper('query', [opts]).then(
(result) => {
const results = Array.isArray(result) ? result : result.results;
const meta = Array.isArray(result) ? {} : result.meta;

this.isBusy = false;
delete this.__currentOpts__;
this.meta = meta;
this.error = undefined;

if (!results) {
throw new Error(`${this}#query: mapper failed to return any results`);
}

if (this.isPaged && typeof meta.totalCount !== 'number') {
throw new Error(`${this}#query: mapper failed to return total count for paged query`);
}

try {
const models = this.__modelClass__.loadAll(results);

if (this.isPaged) {
this.length = meta.totalCount;
this.splice.apply(this, [(opts.page - 1) * this.baseOpts.pageSize, models.length].concat(models));
}
else {
this.replace(models);
}
}
catch (e) { console.error(e); throw e; }
},
(e) => {
this.isBusy = false;
delete this.__currentOpts__;
this.error = e;
return Promise.reject(e);
}
);
}

return this;
}

// Internal: Provides the implementation of the query array's `then` method.
function queryArrayThen(f1, f2) { return this.__promise__.then(f1, f2); }

// Internal: Provides the implementation of the query array's `catch` method.
function queryArrayCatch(f) { return this.__promise__.catch(f); }

// Internal: Provides the implementation of the query array's `at` method.
function queryArrayAt(i) {
const r = TransisArray.prototype.at.apply(this, arguments);
const pageSize = this.baseOpts && this.baseOpts.pageSize;

if (arguments.length === 1 && !r && pageSize) {
this.query({page: Math.floor(i / pageSize) + 1});
}

return r;
}

// Internal: Sets the given object on a `hasOne` property.
//
// desc - An association descriptor.
Expand Down Expand Up @@ -496,7 +579,9 @@ var Model = TransisObject.extend(function() {
// The array returned by this method is decorated with the following additional properties:
//
// modelClass - The `Transis.Model` subclass that `buildQuery` was invoked on.
// baseOpts - The options passed to this method. These options will be sent with every query.
// isBusy - Boolean property indicating whether a query is in progress.
// isPaged - Indicates whether the query array pages its results.
// error - An error message set on the array when the mapper fails to fulfill its promise.
// meta - Metadata provided by the mapper. May be used for paging results.
//
Expand All @@ -512,6 +597,28 @@ var Model = TransisObject.extend(function() {
// If the this method is called while the array is currently busy, then the call to the mapper
// is queued until the current query completes.
//
// Passing the `pageSize` option to `buildQuery` turns the query into a paged query. Paged
// queries load a page of results at a time instead of simply replacing their contents on each
// call to `#query`. In order for this to work the mapper must resolve its promise with an
// object that contains a `results` key pointing to an array of records and a `meta` key that
// points to an object containing a `totalCount` key. The length of the query array will be set
// to the value of `totalCount` and the results will be spliced into the array at the offset
// indicated by the `page` option. Any models from pages loaded previously will remain in the
// array.
//
// When an item is accessed via the `#at` method from a page that has yet to be fetched, the
// query array will automatically invoke the mapper to fetch that page. This effectively gives
// you a sparse array that will automatically lazily load its contents when then are needed.
// This behavior works very well with a virtualized list component.
//
// Here is an example of the object expected from the mapper:
//
// {
// meta: {totalCount: 321},
// results: [{id: 1}, {id: 2}, {id: 3}]
// }
//
//
// opts - An object to pass along to the mapper (default: `{}`).
//
// Returns the receiver.
Expand All @@ -537,63 +644,31 @@ var Model = TransisObject.extend(function() {
//
// baseOpts - An object to pass along to the mapper method when the query is executed with the
// `#query` method. Any options passed to the `#query` method will be merged in with
// the options given here (default: `{}`).
// the options given here (default: `{}`). The `pageSize` option in particular is
// special as it makes the resulting query array a paged query. See the discussion
// above.
//
// Returns a new `Transis.Array` decorated with the properties and methods described above.
this.buildQuery = function(baseOpts = {}) {
var modelClass = this, promise = Promise.resolve(), a = TransisArray.of(), queued;
let a = TransisArray.of();

a.__modelClass__ = this;
a.__promise__ = Promise.resolve();

a.props({
modelClass: {get: function() { return modelClass; }},
modelClass: {get: function() { return this.__modelClass__; }},
baseOpts: {},
isBusy: {default: false},
isPaged: {on: ['baseOpts'], get: (baseOpts) => typeof baseOpts.pageSize === 'number'},
error: {},
meta: {}
});

a.query = function(queryOpts = {}) {
const opts = Object.assign({}, baseOpts, queryOpts);

if (this.isBusy) {
if (!queued) {
promise = promise.then(() => {
this.query(queued);
queued = undefined;
return promise;
});
}

queued = opts;
}
else {
this.isBusy = true;
promise = modelClass._callMapper('query', [opts]).then(
(result) => {
try {
if (Array.isArray(result)) {
this.replace(modelClass.loadAll(result));
}
else if (result.results) {
this.replace(modelClass.loadAll(result.results));
this.meta = result.meta;
}
}
catch (e) { console.error(e); throw e; }
this.isBusy = false;
this.error = undefined;
},
(e) => {
this.isBusy = false;
this.error = e;
return Promise.reject(e);
}
);
}

return this;
};

a.then = function(f1, f2) { return promise.then(f1, f2); };
a.catch = function(f) { return promise.catch(f); };
a.baseOpts = baseOpts;
a.query = queryArrayQuery;
a.then = queryArrayThen;
a.catch = queryArrayCatch;
a.at = queryArrayAt;

return a;
};
Expand Down

0 comments on commit f80109e

Please sign in to comment.