Skip to content

Commit

Permalink
Allow filtering by relationships (#515)
Browse files Browse the repository at this point in the history
Implements filtering by relationships on both the `Store` and `JSONAPISource`
  • Loading branch information
cibernox authored and dgeb committed Jul 20, 2018
1 parent a8b2ac7 commit 0bec450
Show file tree
Hide file tree
Showing 8 changed files with 1,021 additions and 502 deletions.
984 changes: 492 additions & 492 deletions package-lock.json

Large diffs are not rendered by default.

22 changes: 19 additions & 3 deletions packages/@orbit/data/src/query-expression.ts
Expand Up @@ -12,19 +12,35 @@ export interface AttributeSortSpecifier extends SortSpecifier {
attribute: string;
}

export type ComparisonOperator = 'equal' | 'gt' | 'lt' | 'gte' | 'lte';
export type ValueComparisonOperator = 'equal' | 'gt' | 'lt' | 'gte' | 'lte';
export type SetComparisonOperator = 'equal' | 'all' | 'some' | 'none';

export interface FilterSpecifier {
op: ComparisonOperator;
op: ValueComparisonOperator | SetComparisonOperator;
kind: string;
}

export interface AttributeFilterSpecifier extends FilterSpecifier {
kind: 'attribute';
op: ValueComparisonOperator,
kind: "attribute";
attribute: string;
value: any;
}

export interface RelatedRecordFilterSpecifier extends FilterSpecifier {
op: SetComparisonOperator,
kind: 'relatedRecord';
relation: string;
record: RecordIdentity | RecordIdentity[] | null;
}

export interface RelatedRecordsFilterSpecifier extends FilterSpecifier {
op: SetComparisonOperator,
kind: 'relatedRecords';
relation: string;
records: RecordIdentity[];
}

export interface PageSpecifier {
kind: string;
}
Expand Down
12 changes: 11 additions & 1 deletion packages/@orbit/data/src/query-term.ts
Expand Up @@ -155,7 +155,17 @@ export class FindRecordsTerm extends QueryTerm {
function parseFilterSpecifier(filterSpecifier: FilterSpecifier): FilterSpecifier {
if (isObject(filterSpecifier)) {
let s = filterSpecifier as FilterSpecifier;
s.kind = s.kind || 'attribute';
if (!s.kind) {
if (s.hasOwnProperty('relation')) {
if (s.hasOwnProperty('record')) {
s.kind = 'relatedRecord';
} else if (s.hasOwnProperty('records')) {
s.kind = 'relatedRecords';
}
} else {
s.kind = 'attribute';
}
}
s.op = s.op || 'equal';
return s;
}
Expand Down
50 changes: 48 additions & 2 deletions packages/@orbit/data/test/query-builder-test.ts
Expand Up @@ -33,7 +33,7 @@ module('QueryBuilder', function(hooks) {
);
});

test('findRecords + filter', function(assert) {
test('findRecords + attribute filter', function(assert) {
assert.deepEqual(
oqb
.findRecords('planet')
Expand All @@ -54,7 +54,7 @@ module('QueryBuilder', function(hooks) {
);
});

test('findRecords + filters', function(assert) {
test('findRecords + attribute filters', function(assert) {
assert.deepEqual(
oqb
.findRecords('planet')
Expand Down Expand Up @@ -82,6 +82,52 @@ module('QueryBuilder', function(hooks) {
);
});

test('findRecords + hasOne filter', function (assert) {
assert.deepEqual(
oqb
.findRecords('planet')
.filter({ relation: 'star', record: { id: '1', type: 'star' } })
.toQueryExpression(),
{
op: 'findRecords',
type: 'planet',
filter: [
{
op: 'equal',
kind: 'relatedRecord',
relation: 'star',
record: { id: '1', type: 'star' }
}
]
}
);
});

test('findRecords + hasMany filter', function (assert) {
assert.deepEqual(
oqb
.findRecords('planet')
.filter({
relation: 'moons',
records: [{ id: '1', type: 'moon' }, { id: '2', type: 'moon' }],
op: 'equal'
})
.toQueryExpression(),
{
op: 'findRecords',
type: 'planet',
filter: [
{
op: 'equal',
kind: 'relatedRecords',
relation: 'moons',
records: [{ id: '1', type: 'moon' }, { id: '2', type: 'moon' }]
}
]
}
);
});

test('findRecords + sort (one field, compact)', function(assert) {
assert.deepEqual(
oqb
Expand Down
17 changes: 16 additions & 1 deletion packages/@orbit/jsonapi/src/lib/pull-operators.ts
Expand Up @@ -11,8 +11,10 @@ import {
FilterSpecifier,
SortSpecifier,
AttributeFilterSpecifier,
RelatedRecordFilterSpecifier,
AttributeSortSpecifier,
buildTransform
buildTransform,
RelatedRecordsFilterSpecifier
} from '@orbit/data';
import JSONAPISource from '../jsonapi-source';
import { DeserializedDocument } from '../jsonapi-serializer';
Expand Down Expand Up @@ -131,6 +133,19 @@ function buildFilterParam(source: JSONAPISource, filterSpecifiers: FilterSpecifi
// Note: We don't know the `type` of the attribute here, so passing `null`
const resourceAttribute = source.serializer.resourceAttribute(null, attributeFilter.attribute);
filters[resourceAttribute] = attributeFilter.value;
} else if (filterSpecifier.kind === 'relatedRecord') {
const relatedRecordFilter = filterSpecifier as RelatedRecordFilterSpecifier;
if (Array.isArray(relatedRecordFilter.record)) {
filters[relatedRecordFilter.relation] = relatedRecordFilter.record.map(e => e.id).join(',');
} else {
filters[relatedRecordFilter.relation] = relatedRecordFilter.record.id;
}
} else if (filterSpecifier.kind === 'relatedRecords') {
if (filterSpecifier.op !== 'equal') {
throw new Error(`Operation "${filterSpecifier.op}" is not supported in JSONAPI for relatedRecords filtering`);
}
const relatedRecordsFilter = filterSpecifier as RelatedRecordsFilterSpecifier;
filters[relatedRecordsFilter.relation] = relatedRecordsFilter.records.map(e => e.id).join(',');
} else {
throw new QueryExpressionParseError('Filter operation ${specifier.op} not recognized for JSONAPISource.', filterSpecifier);
}
Expand Down
116 changes: 115 additions & 1 deletion packages/@orbit/jsonapi/test/jsonapi-source-test.ts
Expand Up @@ -858,7 +858,7 @@ module('JSONAPISource', function(hooks) {
});
});

test('#pull - records with filter', function(assert) {
test('#pull - records with attribute filter', function(assert) {
assert.expect(5);

const data = [
Expand All @@ -881,6 +881,120 @@ module('JSONAPISource', function(hooks) {
});
});

test('#pull - records with relatedRecord filter (single value)', function(assert) {
assert.expect(5);

const data = [
{
id: 'moon',
type: 'moons',
attributes: { name: 'Moon' },
relationships: {
planet: { data: { id: 'earth', type: 'planets' } }
}
}
];

fetchStub
.withArgs(`/moons?${encodeURIComponent('filter[planet]')}=earth`)
.returns(jsonapiResponse(200, { data }));

return source.pull(q => q.findRecords('moon')
.filter({ relation: 'planet', record: { id: 'earth', type: 'planets' } }))
.then(transforms => {
assert.equal(transforms.length, 1, 'one transform returned');
assert.deepEqual(transforms[0].operations.map(o => o.op), ['replaceRecord']);
assert.deepEqual(transforms[0].operations.map((o: ReplaceRecordOperation) => o.record.attributes.name), ['Moon']);

assert.equal(fetchStub.callCount, 1, 'fetch called once');
assert.equal(fetchStub.getCall(0).args[1].method, undefined, 'fetch called with no method (equivalent to GET)');
});
});

test('#pull - records with relatedRecord filter (multiple values)', function(assert) {
assert.expect(5);

const data = [
{
id: 'moon',
type: 'moons',
attributes: { name: 'Moon' },
relationships: {
planet: { data: { id: 'earth', type: 'planets' } }
}
},
{
id: 'phobos',
type: 'moons',
attributes: { name: 'Phobos' },
relationships: {
planet: { data: { id: 'mars', type: 'planets' } }
}
},
{
id: 'deimos',
type: 'moons',
attributes: { name: 'Deimos' },
relationships: {
planet: { data: { id: 'mars', type: 'planets' } }
}
}
];

fetchStub
.withArgs(`/moons?${encodeURIComponent('filter[planet]')}=${encodeURIComponent('earth,mars')}`)
.returns(jsonapiResponse(200, { data }));

return source.pull(q => q.findRecords('moon')
.filter({ relation: 'planet', record: [{ id: 'earth', type: 'planets' }, { id: 'mars', type: 'planets' }] }))
.then(transforms => {
assert.equal(transforms.length, 1, 'one transform returned');
assert.deepEqual(transforms[0].operations.map(o => o.op), [
'replaceRecord',
'replaceRecord',
'replaceRecord'
]);
assert.deepEqual(transforms[0].operations.map((o: ReplaceRecordOperation) => o.record.attributes.name), ['Moon', 'Phobos', 'Deimos']);

assert.equal(fetchStub.callCount, 1, 'fetch called once');
assert.equal(fetchStub.getCall(0).args[1].method, undefined, 'fetch called with no method (equivalent to GET)');
});
});

test('#pull - records with relatedRecords filter', function(assert) {
assert.expect(5);

const data = [
{
id: 'mars',
type: 'planets',
attributes: { name: 'Mars' },
relationships: {
moons: { data: [{ id: 'phobos', type: 'moons' }, { id: 'deimos', type: 'moons' }] }
}
}
];

fetchStub
.withArgs(`/planets?${encodeURIComponent('filter[moons]')}=${encodeURIComponent('phobos,deimos')}`)
.returns(jsonapiResponse(200, { data }));

return source.pull(q => q.findRecords('planet')
.filter({
relation: 'moons',
records: [{ id: 'phobos', type: 'moons' }, { id: 'deimos', type: 'moons' }],
op: 'equal'
}))
.then(transforms => {
assert.equal(transforms.length, 1, 'one transform returned');
assert.deepEqual(transforms[0].operations.map(o => o.op), ['replaceRecord']);
assert.deepEqual(transforms[0].operations.map((o: ReplaceRecordOperation) => o.record.attributes.name), ['Mars']);

assert.equal(fetchStub.callCount, 1, 'fetch called once');
assert.equal(fetchStub.getCall(0).args[1].method, undefined, 'fetch called with no method (equivalent to GET)');
});
});

test('#pull - records with sort by an attribute in ascending order', function(assert) {
assert.expect(5);

Expand Down
31 changes: 31 additions & 0 deletions packages/@orbit/store/src/cache/query-operators.ts
Expand Up @@ -98,6 +98,37 @@ function applyFilter(record, filter) {
default:
throw new QueryExpressionParseError('Filter operation ${filter.op} not recognized for Store.', filter);
}
} else if (filter.kind === 'relatedRecords') {
let relation = deepGet(record, ['relationships', filter.relation]);
let actual = relation === undefined ? [] : relation.data;
let expected = filter.records;
switch (filter.op) {
case 'equal':
return actual.length === expected.length
&& expected.every(e => actual.some(a => a.id === e.id && a.type === e.type));
case 'all':
return expected.every(e => actual.some(a => a.id === e.id && a.type === e.type));
case 'some':
return expected.some(e => actual.some(a => a.id === e.id && a.type === e.type));
case 'none':
return !expected.some(e => actual.some(a => a.id === e.id && a.type === e.type));
default:
throw new QueryExpressionParseError('Filter operation ${filter.op} not recognized for Store.', filter);
}
} else if (filter.kind === 'relatedRecord') {
let relation = deepGet(record, ["relationships", filter.relation]);
let actual = relation === undefined ? undefined : relation.data;
let expected = filter.record;
switch (filter.op) {
case 'equal':
if (Array.isArray(expected)) {
return actual !== undefined && expected.some(e => actual.type === e.type && actual.id === e.id);
} else {
return actual !== undefined && actual.type === expected.type && actual.id === expected.id;
}
default:
throw new QueryExpressionParseError('Filter operation ${filter.op} not recognized for Store.', filter);
}
}
return false;
}
Expand Down

0 comments on commit 0bec450

Please sign in to comment.