Skip to content

Commit 93f8e97

Browse files
committed
Replace raw result array with CollectionProxy
This wraps the results of any collection requests in an CollectionProxy instance, which allows users to access additional metadata about the result set. Users can call `CollectionProxy.data` to get the parsed model array, or may access the original request body with `CollectionProxy.raw`. Additionally, the `meta` field of the response is exposed as a first-class member, in `CollectionProxy#meta`. Follow-up items include similar wrapping for scope and model methods that are expecting a single result, wrapping them in a `RecordProxy` or something similar.
1 parent bbf44a6 commit 93f8e97

File tree

9 files changed

+165
-22
lines changed

9 files changed

+165
-22
lines changed

index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ declare module NodeJS {
1414
interface japiDoc {
1515
data: any; // can't do Array | japiResource
1616
included: Array<japiResource>;
17+
meta?: any;
1718
}
1819

1920
interface japiResourceIdentifier {

src/associations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Attribute from './attribute';
22
import Model from './model';
3+
import CollectionProxy from './collection-proxy';
34

45
class Base extends Attribute {
56
klass: typeof Model;

src/collection-proxy.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Model from './model';
2+
3+
class CollectionProxy<T> {
4+
private _raw_json : japiDoc;
5+
private _array : Array<T>;
6+
7+
constructor (raw_json : japiDoc = { data: [], included: [] }) {
8+
this.setRaw(raw_json)
9+
}
10+
11+
get raw () : japiDoc {
12+
return this._raw_json
13+
}
14+
15+
get data () : Array<T> {
16+
return this._array
17+
}
18+
19+
get collection () : Array<T> {
20+
return this._array
21+
}
22+
23+
get meta () : Object {
24+
if (this.raw) {
25+
return this.raw.meta || {}
26+
}
27+
28+
return {}
29+
}
30+
31+
map (fn) {
32+
return this.data.map(fn)
33+
}
34+
35+
private setRaw = (json_payload : japiDoc) => {
36+
this._raw_json = json_payload
37+
38+
this._array = []
39+
40+
if (this.raw.data) {
41+
this.raw.data.map((datum : japiResource) => {
42+
this._array.push(Model.fromJsonapi(datum, this.raw));
43+
});
44+
}
45+
}
46+
}
47+
48+
export default CollectionProxy

src/model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Scope from './scope';
44
import Config from './configuration';
55
import Attribute from './attribute';
66
import deserialize from './util/deserialize';
7+
import CollectionProxy from './collection-proxy';
78
import _extend from './util/extend';
89
import { camelize } from './util/string';
910

@@ -41,7 +42,7 @@ export default class Model {
4142
this.attributes = attributes;
4243
}
4344

44-
static all() : Promise<Array<Model>> {
45+
static all() : Promise<CollectionProxy<Model>> {
4546
return this.scope().all();
4647
}
4748

src/scope.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Model from './model';
22
import Config from './configuration';
33
import parameterize from './util/parameterize';
44
import IncludeDirective from './util/include-directive';
5+
import CollectionProxy from './collection-proxy';
56
import Request from './request';
67
import colorize from './util/colorize';
78

@@ -19,11 +20,11 @@ export default class Scope {
1920
this.model = model;
2021
}
2122

22-
all() : Promise<Array<Model>> {
23+
all() : Promise<CollectionProxy<Model>> {
2324
return this._fetch(this.model.url()).then((json : japiDoc) => {
24-
return json.data.map((datum : japiResource) => {
25-
return Model.fromJsonapi(datum, json);
26-
});
25+
let collection = new CollectionProxy(json)
26+
27+
return collection
2728
});
2829
}
2930

@@ -35,8 +36,8 @@ export default class Scope {
3536

3637
// TODO: paginate 1
3738
first() : Promise<Model> {
38-
return this.per(1).all().then((models : Array<Model>) => {
39-
return models[0];
39+
return this.per(1).all().then((models : CollectionProxy<Model>) => {
40+
return models.data[0];
4041
});
4142
}
4243

test/integration/finders-test.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ after(function () {
77
fetchMock.restore();
88
});
99

10+
let resultData = function(promise) {
11+
return promise.then(function(collection) {
12+
return collection.data
13+
})
14+
}
15+
1016
describe('Model finders', function() {
1117
describe('#find()', function() {
1218
before(function () {
@@ -71,7 +77,8 @@ describe('Model finders', function() {
7177
});
7278

7379
describe('#all()', function() {
74-
before(function () {
80+
beforeEach(function () {
81+
fetchMock.restore();
7582
fetchMock.get('http://example.com/api/v1/people', {
7683
data: [
7784
{ id: '1', type: 'people' }
@@ -80,10 +87,33 @@ describe('Model finders', function() {
8087
});
8188

8289
it('returns a promise that resolves the correct instances', function() {
83-
return expect(Person.all()).to.eventually
90+
return expect(resultData(Person.all())).to.eventually
8491
.all.be.instanceof(Person)
8592
.all.have.property('id', '1')
8693
});
94+
95+
describe('response includes a meta payload', function() {
96+
beforeEach(function () {
97+
fetchMock.restore();
98+
fetchMock.get('http://example.com/api/v1/people', {
99+
data: [
100+
{ id: '1', type: 'people' }
101+
],
102+
meta: {
103+
stats: {
104+
total: {
105+
count: 45
106+
},
107+
}
108+
}
109+
});
110+
});
111+
112+
it('promise collection includes meta payload', function() {
113+
return expect(Person.all()).to.eventually
114+
.have.deep.property('meta.stats.total.count', 45)
115+
});
116+
});
87117
});
88118

89119
describe('#page', function() {
@@ -96,25 +126,24 @@ describe('Model finders', function() {
96126
});
97127

98128
it('queries correctly', function() {
99-
return expect(Person.page(2).all()).to.eventually
129+
return expect(resultData(Person.page(2).all())).to.eventually
100130
.all.be.instanceof(Person)
101131
.all.have.property('id', '2')
102132
});
103133
});
104134

105135
describe('#per', function() {
106136
before(function () {
107-
fetchMock.get('http://example.com/api/v1/people?page[size]=10', {
137+
fetchMock.get('http://example.com/api/v1/people?page[size]=2', {
108138
data: [
109-
{ id: '2', type: 'people' }
139+
{ id: '1', type: 'people' }
110140
]
111141
});
112142
});
113143

114144
it('queries correctly', function() {
115-
return expect(Person.page(2).all()).to.eventually
145+
return expect(resultData(Person.per(2).all())).to.eventually
116146
.all.be.instanceof(Person)
117-
.all.have.property('id', '2')
118147
});
119148
});
120149

@@ -128,7 +157,7 @@ describe('Model finders', function() {
128157
});
129158

130159
it('queries correctly', function() {
131-
return expect(Person.order('foo').order({ bar: 'desc' }).all()).to.eventually
160+
return expect(resultData(Person.order('foo').order({ bar: 'desc' }).all())).to.eventually
132161
.all.be.instanceof(Person)
133162
.all.have.property('id', '2')
134163
});
@@ -144,7 +173,7 @@ describe('Model finders', function() {
144173
});
145174

146175
it('queries correctly', function() {
147-
return expect(Person.where({ id: 2 }).where({ a: 'b' }).all()).to.eventually
176+
return expect(resultData(Person.where({ id: 2 }).where({ a: 'b' }).all())).to.eventually
148177
.all.be.instanceof(Person)
149178
.all.have.property('id', '2')
150179
});
@@ -160,7 +189,7 @@ describe('Model finders', function() {
160189
});
161190

162191
it('queries correctly', function() {
163-
return expect(Person.select({ people: ['name', 'age'] }).all()).to.eventually
192+
return expect(resultData(Person.select({ people: ['name', 'age'] }).all())).to.eventually
164193
.all.be.instanceof(Person)
165194
.all.have.property('id', '2')
166195
});
@@ -176,7 +205,7 @@ describe('Model finders', function() {
176205
});
177206

178207
it('queries correctly', function() {
179-
return expect(Person.selectExtra({ people: ['net_worth', 'best_friend'] }).all()).to.eventually
208+
return expect(resultData(Person.selectExtra({ people: ['net_worth', 'best_friend'] }).all())).to.eventually
180209
.all.be.instanceof(Person)
181210
.all.have.property('id', '2')
182211
});
@@ -195,7 +224,7 @@ describe('Model finders', function() {
195224
});
196225

197226
it('queries correctly', function() {
198-
return expect(Person.includes({ a: ['b', { c: 'd' }] }).all()).to.eventually
227+
return expect(resultData(Person.includes({ a: ['b', { c: 'd' }] }).all())).to.eventually
199228
.all.be.instanceof(Person)
200229
.all.have.property('id', '2')
201230
});

test/unit/collection-proxy-test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { sinon } from '../../test/test-helper';
2+
import { Person } from '../fixtures';
3+
4+
import CollectionProxy from '../../src/collection-proxy'
5+
6+
beforeEach(function() {
7+
});
8+
9+
describe('CollectionProxy', function() {
10+
let personData = {
11+
data: [
12+
{
13+
id: '1',
14+
type: 'people',
15+
attributes: {
16+
firstName: 'Donald',
17+
lastName: 'Budge'
18+
},
19+
}
20+
],
21+
included: [],
22+
meta: {
23+
stats: {
24+
total: {
25+
count: 3
26+
},
27+
average: {
28+
salary: "$100k"
29+
}
30+
}
31+
}
32+
}
33+
34+
describe('initialization', function() {
35+
it('should assign the response correctly', function() {
36+
let collection = new CollectionProxy(personData)
37+
expect(collection.raw).to.deep.equal(personData)
38+
})
39+
40+
it('should assign the correct models to the data array', function() {
41+
let collection = new CollectionProxy(personData)
42+
expect(collection.data).all.to.be.instanceof(Person)
43+
})
44+
})
45+
46+
describe('#meta', function() {
47+
it('should get meta field from raw response', function() {
48+
let collection = new CollectionProxy(personData)
49+
expect(collection.meta).to.eq(personData.meta)
50+
})
51+
})
52+
53+
describe('#collection', function() {
54+
it('should alias the data field', function() {
55+
let collection = new CollectionProxy(personData)
56+
expect(collection.collection).to.eq(collection.data)
57+
})
58+
})
59+
})
60+

test/unit/model-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ describe('Model', function() {
174174

175175
it('skips relationships without data', function() {
176176
let instance = Model.fromJsonapi(doc.data, doc);
177-
expect(instance.tags).to.eql([]);
177+
expect(instance.tags.length).to.eql(0);
178178
});
179179
});
180180
});

test/unit/relationships-test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import { sinon } from '../../test/test-helper';
44
import { Author, Genre } from '../fixtures';
55

6+
import CollectionProxy from '../../src/collection-proxy';
7+
68
describe('Model relationships', function() {
79
it('supports direct assignment of models', function() {
810
let author = new Author();
@@ -31,8 +33,8 @@ describe('Model relationships', function() {
3133
expect(author.genre.name).to.eq('Horror');
3234
});
3335

34-
it('defaults hasMany to empty array', function() {
36+
it('defaults hasMany to empty collection', function() {
3537
let genre = new Genre();
36-
expect(genre.authors).to.eql([]);
38+
expect(genre.authors.length).to.eql(0);
3739
});
3840
});

0 commit comments

Comments
 (0)