Skip to content

Commit fff7576

Browse files
committed
Merge pull request #43 from js-data/develop
Add support for "near" filtering, closes #41
2 parents 9dbd048 + 34e12e5 commit fff7576

File tree

6 files changed

+280
-42
lines changed

6 files changed

+280
-42
lines changed

mocha.start.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ beforeEach(function () {
5050
globals.store = global.store = this.$$store;
5151
globals.User = global.User = this.$$User;
5252
globals.Profile = global.Profile = this.$$Profile;
53+
globals.Address = global.Address = this.$$Address;
5354
globals.Post = global.Post = this.$$Post;
5455
globals.Comment = global.Comment = this.$$Comment;
5556
});

src/index.js

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,50 @@ function getTable (resourceConfig) {
1818
return resourceConfig.table || underscore(resourceConfig.name)
1919
}
2020

21+
/**
22+
* Lookup and apply table joins to query if field contains a `.`
23+
* @param {string} field - Field defined in where filter
24+
* @param {object} query - knex query to modify
25+
* @param {object} resourceConfig - Resource of primary query/table
26+
* @param {string[]} existingJoins - Array of fully qualitifed field names for
27+
* any existing table joins for query
28+
* @returns {string} - field updated to perspective of applied joins
29+
*/
30+
function applyTableJoins (field, query, resourceConfig, existingJoins) {
31+
if (DSUtils.contains(field, '.')) {
32+
let parts = field.split('.')
33+
let localResourceConfig = resourceConfig
34+
35+
let relationPath = []
36+
while (parts.length >= 2) {
37+
let relationName = parts.shift()
38+
let relationResourceConfig = resourceConfig.getResource(relationName)
39+
relationPath.push(relationName)
40+
41+
if (!existingJoins.some(t => t === relationPath.join('.'))) {
42+
let [relation] = localResourceConfig.relationList.filter(r => r.relation === relationName)
43+
if (relation) {
44+
let table = getTable(localResourceConfig)
45+
let localId = `${table}.${relation.localKey}`
46+
47+
let relationTable = getTable(relationResourceConfig)
48+
let foreignId = `${relationTable}.${relationResourceConfig.idAttribute}`
49+
50+
query.join(relationTable, localId, foreignId)
51+
existingJoins.push(relationPath.join('.'))
52+
} else {
53+
// hopefully a qualified local column
54+
}
55+
}
56+
localResourceConfig = relationResourceConfig
57+
}
58+
59+
field = `${getTable(localResourceConfig)}.${parts[0]}`
60+
}
61+
62+
return field;
63+
}
64+
2165
function loadWithRelations (items, resourceConfig, options) {
2266
let tasks = []
2367
let instance = Array.isArray(items) ? null : items
@@ -283,35 +327,12 @@ class DSSqlAdapter {
283327
}
284328

285329
DSUtils.forOwn(criteria, (v, op) => {
286-
if (DSUtils.contains(field, '.')) {
287-
let parts = field.split('.')
288-
let localResourceConfig = resourceConfig
289-
290-
let relationPath = []
291-
while (parts.length >= 2) {
292-
let relationName = parts.shift()
293-
let relationResourceConfig = resourceConfig.getResource(relationName)
294-
relationPath.push(relationName)
295-
296-
if (!joinedTables.some(t => t === relationPath.join('.'))) {
297-
let [relation] = localResourceConfig.relationList.filter(r => r.relation === relationName)
298-
if (relation) {
299-
let table = getTable(localResourceConfig)
300-
let localId = `${table}.${relation.localKey}`
301-
302-
let relationTable = getTable(relationResourceConfig)
303-
let foreignId = `${relationTable}.${relationResourceConfig.idAttribute}`
304-
305-
query = query.join(relationTable, localId, foreignId)
306-
joinedTables.push(relationPath.join('.'))
307-
} else {
308-
// local column
309-
}
310-
}
311-
localResourceConfig = relationResourceConfig
312-
}
313-
314-
field = `${getTable(localResourceConfig)}.${parts[0]}`
330+
// Apply table joins (if needed)
331+
if (DSUtils.contains(field, ',')) {
332+
let splitFields = field.split(',').map(c => c.trim())
333+
field = splitFields.map(splitField => applyTableJoins(splitField, query, resourceConfig, joinedTables)).join(',');
334+
} else {
335+
field = applyTableJoins(field, query, resourceConfig, joinedTables);
315336
}
316337

317338
if (op === '==' || op === '===') {
@@ -334,6 +355,45 @@ class DSSqlAdapter {
334355
query = query.where(field, 'in', v)
335356
} else if (op === 'notIn') {
336357
query = query.whereNotIn(field, v)
358+
} else if (op === 'near') {
359+
const milesRegex = /(\d+(\.\d+)?)\s*(m|M)iles$/;
360+
const kilometersRegex = /(\d+(\.\d+)?)\s*(k|K)$/;
361+
362+
let radius;
363+
let unitsPerDegree;
364+
if (typeof v.radius === 'number' || milesRegex.test(v.radius)) {
365+
radius = typeof v.radius === 'number' ? v.radius : v.radius.match(milesRegex)[1]
366+
unitsPerDegree = 69.0; // miles per degree
367+
} else if (kilometersRegex.test(v.radius)) {
368+
radius = v.radius.match(kilometersRegex)[1]
369+
unitsPerDegree = 111.045; // kilometers per degree;
370+
} else {
371+
throw new Error('Unknown radius distance units')
372+
}
373+
374+
let [latitudeColumn, longitudeColumn] = field.split(',').map(c => c.trim())
375+
let [latitude, longitude] = v.center;
376+
377+
// Uses indexes on `latitudeColumn` / `longitudeColumn` if available
378+
query = query
379+
.whereBetween(latitudeColumn, [
380+
latitude - (radius / unitsPerDegree),
381+
latitude + (radius / unitsPerDegree)
382+
])
383+
.whereBetween(longitudeColumn, [
384+
longitude - (radius / (unitsPerDegree * Math.cos(latitude * (Math.PI / 180)))),
385+
longitude + (radius / (unitsPerDegree * Math.cos(latitude * (Math.PI / 180))))
386+
])
387+
388+
if (v.calculateDistance) {
389+
let distanceColumn = (typeof v.calculateDistance === 'string') ? v.calculateDistance : 'distance'
390+
query = query.select(knex.raw(`
391+
${unitsPerDegree} * DEGREES(ACOS(
392+
COS(RADIANS(?)) * COS(RADIANS(${latitudeColumn})) *
393+
COS(RADIANS(${longitudeColumn}) - RADIANS(?)) +
394+
SIN(RADIANS(?)) * SIN(RADIANS(${latitudeColumn}))
395+
)) AS ${distanceColumn}`, [latitude, longitude, latitude]))
396+
}
337397
} else if (op === 'like') {
338398
query = query.where(field, 'like', v)
339399
} else if (op === '|==' || op === '|===') {

test/create_trx.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe('DSSqlAdapter#create + transaction', function () {
1313
assert.isObject(findUser, 'user committed to database');
1414
assert.equal(findUser.name, 'Jane');
1515
assert.isDefined(findUser.id);
16-
assert.equalObjects(findUser, {id: id, name: 'Jane', age: null, profileId: null});
16+
assert.equalObjects(findUser, {id: id, name: 'Jane', age: null, profileId: null, addressId: null});
1717
});
1818

1919
it('rollback should not persist created user in a sql db', function* () {

test/findAll.spec.js

Lines changed: 167 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,181 @@
1+
'use strict';
2+
13
describe('DSSqlAdapter#findAll', function () {
24
it('should not return relation columns on parent', function* () {
3-
var profile1 = yield adapter.create(Profile, { email: '[email protected]' });
4-
var user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id});
5+
let profile1 = yield adapter.create(Profile, { email: '[email protected]' });
6+
let user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id});
57

6-
var users = yield adapter.findAll(User, {'profile.email': '[email protected]'});
8+
let users = yield adapter.findAll(User, {'profile.email': '[email protected]'});
79
assert.equal(users.length, 1);
810
assert.equal(users[0].profileId, profile1.id);
911
assert.isUndefined(users[0].email);
1012
});
1113

1214
it('should filter when relations have same column if column is qualified', function* () {
13-
var profile1 = yield adapter.create(Profile, { email: '[email protected]' });
14-
var user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id});
15+
let profile1 = yield adapter.create(Profile, { email: '[email protected]' });
16+
let user1 = yield adapter.create(User, {name: 'John', profileId: profile1.id});
1517

1618
// `id` column must be qualified with `user.`
17-
var users = yield adapter.findAll(User, {'user.id': user1.id, 'profile.email': '[email protected]'});
19+
let users = yield adapter.findAll(User, {'user.id': user1.id, 'profile.email': '[email protected]'});
1820
assert.equal(users.length, 1);
1921
assert.equal(users[0].profileId, profile1.id);
2022
});
21-
});
23+
24+
describe('near', function () {
25+
beforeEach(function * () {
26+
this.googleAddress = yield adapter.create(Address, { name : 'Google', latitude: 37.4219999, longitude: -122.0862515 });
27+
this.appleAddress = yield adapter.create(Address, { name : 'Apple', latitude: 37.331852, longitude: -122.029599 });
28+
this.microsoftAddress = yield adapter.create(Address, { name : 'Microsoft', latitude: 47.639649, longitude: -122.128255 });
29+
this.amazonAddress = yield adapter.create(Address, { name : 'Amazon', latitude: 47.622915, longitude: -122.336384 });
30+
})
31+
32+
it('should filter using "near"', function* () {
33+
let addresses = yield adapter.findAll(Address, {
34+
where: {
35+
'latitude,longitude': {
36+
'near': {
37+
center: [37.41, -122.06],
38+
radius: 10
39+
}
40+
}
41+
}
42+
});
43+
assert.equal(addresses.length, 2);
44+
assert.equal(addresses[0].name, 'Google');
45+
assert.equal(addresses[1].name, 'Apple');
46+
})
47+
48+
it('should not contain distance column by default', function* () {
49+
let addresses = yield adapter.findAll(Address, {
50+
where: {
51+
'latitude,longitude': {
52+
'near': {
53+
center: [37.41, -122.06],
54+
radius: 5
55+
}
56+
}
57+
}
58+
});
59+
assert.equal(addresses.length, 1);
60+
assert.equal(addresses[0].name, 'Google');
61+
assert.equal(addresses[0].distance, undefined);
62+
})
63+
64+
it('should contain distance column if "calculateDistance" is truthy', function* () {
65+
let addresses = yield adapter.findAll(Address, {
66+
where: {
67+
'latitude,longitude': {
68+
'near': {
69+
center: [37.41, -122.06],
70+
radius: 10,
71+
calculateDistance: true
72+
}
73+
}
74+
}
75+
});
76+
assert.equal(addresses.length, 2);
77+
78+
assert.equal(addresses[0].name, 'Google');
79+
assert.isNotNull(addresses[0].distance);
80+
assert.equal(Math.round(addresses[0].distance), 2);
81+
82+
assert.equal(addresses[1].name, 'Apple');
83+
assert.isNotNull(addresses[1].distance);
84+
assert.equal(Math.round(addresses[1].distance), 6);
85+
})
86+
87+
it('should contain custom distance column if "calculateDistance" is string', function* () {
88+
let addresses = yield adapter.findAll(Address, {
89+
where: {
90+
'latitude,longitude': {
91+
'near': {
92+
center: [37.41, -122.06],
93+
radius: 10,
94+
calculateDistance: 'howfar'
95+
}
96+
}
97+
}
98+
});
99+
assert.equal(addresses.length, 2);
100+
101+
assert.equal(addresses[0].name, 'Google');
102+
assert.equal(addresses[0].distance, undefined);
103+
assert.isNotNull(addresses[0].howfar);
104+
assert.equal(Math.round(addresses[0].howfar), 2);
105+
106+
assert.equal(addresses[1].name, 'Apple');
107+
assert.equal(addresses[1].distance, undefined);
108+
assert.isNotNull(addresses[1].howfar);
109+
assert.equal(Math.round(addresses[1].howfar), 6);
110+
})
111+
112+
it('should use kilometers instead of miles if radius ends with "k"', function* () {
113+
let addresses = yield adapter.findAll(Address, {
114+
where: {
115+
'latitude,longitude': {
116+
'near': {
117+
center: [37.41, -122.06],
118+
radius: '10k',
119+
calculateDistance: true
120+
}
121+
}
122+
}
123+
});
124+
assert.equal(addresses.length, 2);
125+
126+
assert.equal(addresses[0].name, 'Google');
127+
assert.isNotNull(addresses[0].distance);
128+
assert.equal(Math.round(addresses[0].distance), 3); // in kilometers
129+
130+
assert.equal(addresses[1].name, 'Apple');
131+
assert.isNotNull(addresses[1].distance);
132+
assert.equal(Math.round(addresses[1].distance), 9); // in kilometers
133+
})
134+
135+
it('should filter through relationships', function* () {
136+
let user1 = yield adapter.create(User, { name : 'Larry Page', addressId: this.googleAddress.id });
137+
let user2 = yield adapter.create(User, { name : 'Tim Cook', addressId: this.appleAddress.id });
138+
139+
let users = yield adapter.findAll(User, {
140+
where: {
141+
'address.latitude, address.longitude': {
142+
'near': {
143+
center: [37.41, -122.06],
144+
radius: 10,
145+
calculateDistance: 'howfar'
146+
}
147+
}
148+
}
149+
});
150+
assert.equal(users.length, 2);
151+
assert.equal(users[0].name, 'Larry Page');
152+
assert.equal(users[1].name, 'Tim Cook');
153+
})
154+
155+
it('should filter through multiple hasOne/belongsTo relations', function * () {
156+
let user1 = yield adapter.create(User, { name : 'Larry Page', addressId: this.googleAddress.id });
157+
var post1 = yield adapter.create(Post, {content: 'foo', userId: user1.id})
158+
yield adapter.create(Comment, {content: 'test1', postId: post1.id, userId: post1.userId})
159+
160+
var user2 = yield adapter.create(User, {name: 'Tim Cook', addressId: this.appleAddress.id})
161+
var post2 = yield adapter.create(Post, {content: 'bar', userId: user2.id})
162+
yield adapter.create(Comment, {content: 'test2', postId: post2.id, userId: post2.userId})
163+
164+
let comments = yield adapter.findAll(Comment, {
165+
where: {
166+
'user.address.latitude, user.address.longitude': {
167+
'near': {
168+
center: [37.41, -122.06],
169+
radius: 5
170+
}
171+
}
172+
}
173+
});
174+
175+
assert.equal(comments.length, 1)
176+
assert.equal(comments[0].userId, user1.id)
177+
assert.equal(comments[0].content, 'test1')
178+
})
179+
180+
})
181+
});

test/setup.sql

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,34 @@
1+
DROP TABLE IF EXISTS `comment`;
2+
DROP TABLE IF EXISTS `post`;
3+
DROP TABLE IF EXISTS `user`;
4+
DROP TABLE IF EXISTS `address`;
5+
DROP TABLE IF EXISTS `profile`;
6+
17
CREATE TABLE `profile` (
28
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
39
`email` varchar(255) NOT NULL DEFAULT '',
410
PRIMARY KEY (`id`)
511
) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1;
612

13+
CREATE TABLE `address` (
14+
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
15+
`name` varchar(255) NOT NULL DEFAULT '',
16+
`latitude` Decimal(10,7) DEFAULT NULL,
17+
`longitude` Decimal(10,7) DEFAULT NULL,
18+
PRIMARY KEY (`id`)
19+
) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1;
20+
721
CREATE TABLE `user` (
822
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
923
`name` varchar(255) NOT NULL DEFAULT '',
1024
`age` int(11) unsigned DEFAULT NULL,
1125
`profileId` int(11) unsigned DEFAULT NULL,
26+
`addressId` int(11) unsigned DEFAULT NULL,
1227
PRIMARY KEY (`id`),
1328
KEY `fk-user-profile` (`profileId`),
14-
CONSTRAINT `fk-user-profile` FOREIGN KEY (`profileId`) REFERENCES `profile` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
29+
KEY `fk-user-address` (`addressId`),
30+
CONSTRAINT `fk-user-profile` FOREIGN KEY (`profileId`) REFERENCES `profile` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
31+
CONSTRAINT `fk-user-address` FOREIGN KEY (`addressId`) REFERENCES `address` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
1532
) ENGINE=InnoDB AUTO_INCREMENT=45 DEFAULT CHARSET=latin1;
1633

1734
CREATE TABLE `post` (

0 commit comments

Comments
 (0)